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": {
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/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/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..d5a033a 100644
--- a/src/app/admin/about/about.ts
+++ b/src/app/admin/about/about.ts
@@ -1,29 +1,89 @@
-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();
+ if (!this.adminQuery.getAbout()) {
+ this.adminState.loadAbout()
+ .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();
+
+ this.popupConfig = {
+ title: 'Edit About',
+ submitLabel: 'Save',
+ api: {
+ save: '/api/v1/admin/UpsertHobbies',
+ method: 'POST'
+ },
+ fields: [
+ { name: 'about', label: 'About', type: 'textarea', required: true },
+ {
+ name: 'hobbies',
+ label: 'Hobbies',
+ type: 'array',
+ itemConfig: [
+ { name: 'hobbyId', label: 'Hobby ID', type: 'hidden' },
+ { name: 'name', label: 'Name', type: 'text', required: true },
+ { name: 'description', label: 'Description', type: 'text', required: true },
+ { name: 'icon', label: 'Icon (FA class)', type: 'text', placeholder: 'e.g. fa-code' }
+ ]
+ }
+ ]
+ };
+
+ this.popupData = {
+ about: currentAbout?.about,
+ hobbies: currentAbout?.hobbies ?? []
+ };
+
+ const ref = this.dialog.open(DynamicPopupComponent, {
+ data: { config: this.popupConfig, data: this.popupData },
+ panelClass: 'dark-popup-panel'
});
+
+ ref.afterClosed()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((res: { about: string; hobbies: IHobby[] } | null) => {
+ if (res) {
+ const updated = { ...currentAbout!, about: res.about, hobbies: res.hobbies ?? currentAbout!.hobbies };
+ this.adminState.updateAbout(updated);
+ }
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
}
}
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..a87a8ea 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();
+ if (!this.adminQuery.getContact()) {
+ this.adminState.loadContact()
+ .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();
+
+ this.popupConfig = {
+ title: 'Edit Contact',
+ submitLabel: 'Save',
+ api: {
+ save: '/api/v1/admin/UpsertContact',
+ method: 'POST'
+ },
+ fields: [
+ { name: 'title', label: 'Title', type: 'text', required: true },
+ { name: 'firstName', label: 'First Name', type: 'text', required: true },
+ { name: 'lastName', label: 'Last Name', type: 'text', required: true },
+ { name: 'email', label: 'Email', type: 'text', required: true },
+ { name: 'phone', label: 'Phone', type: 'text', required: true },
+ { name: 'dob', label: 'Date of Birth', type: 'date' },
+ { name: 'address', label: 'Address', type: 'textarea' },
+ { name: 'linkedin', label: 'LinkedIn URL', type: 'text' },
+ { name: 'gitHub', label: 'GitHub URL', type: 'text' },
+ { name: 'blogUrl', label: 'Blog URL', type: 'text' }
+ ]
+ };
+
+ this.popupData = {
+ title: current?.title,
+ firstName: current?.candidate?.firstName,
+ lastName: current?.candidate?.lastName,
+ email: current?.candidate?.email,
+ phone: current?.candidate?.phone,
+ dob: current?.candidate?.dob,
+ address: current?.candidate?.address,
+ linkedin: current?.socialLinks?.linkedin,
+ gitHub: current?.socialLinks?.gitHub,
+ blogUrl: current?.socialLinks?.blogUrl
+ };
+
+ const ref = this.dialog.open(DynamicPopupComponent, {
+ data: { config: this.popupConfig, data: this.popupData },
+ panelClass: 'dark-popup-panel'
});
+
+ ref.afterClosed()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((res: IContactModel) => {
+ if (res) {
+ const updated = {
+ ...current!,
+ title: res.title ?? '',
+ candidate: {
+ ...current!.candidate!,
+ firstName: res.candidate?.firstName ?? '',
+ lastName: res.candidate?.lastName ?? '',
+ displayName: `${res.candidate?.firstName ?? ''} ${res.candidate?.lastName ?? ''}`,
+ email: res.candidate?.email ?? '',
+ phone: res.candidate?.phone ?? '',
+ dob: res.candidate?.dob ?? '',
+ address: res.candidate?.address ?? ''
+ },
+ socialLinks: {
+ ...current!.socialLinks!,
+ linkedin: res.socialLinks?.linkedin ?? '',
+ gitHub: res.socialLinks?.gitHub ?? '',
+ blogUrl: res.socialLinks?.blogUrl ?? ''
+ }
+ };
+ this.adminState.updateContact(updated);
+ }
+ });
}
logout() {
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..38ad3f6 100644
--- a/src/app/admin/models/project.model.ts
+++ b/src/app/admin/models/project.model.ts
@@ -3,9 +3,15 @@ export interface IProject{
name: string;
description: string;
categories: string[];
- categoryList: string[];
roles: string[];
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 @@
+
+
+
+ @if (project.status) {
+
{{ project.status }}
+ }
+
+ @if (project.description) {
+
+
Description
+
{{ project.description }}
+
+ }
+
+ @if (project.roles.length) {
+
+
Roles
+
+ @for (role of project.roles; track role) {
+ {{ role }}
+ }
+
+
+ }
+
+ @if (project.responsibilities.length) {
+
+
Responsibilities
+
+ @for (r of project.responsibilities; track r) {
+ {{ r }}
+ }
+
+
+ }
+
+ @if (project.technologiesUsed.length) {
+
+
Technologies
+
+ @for (tech of project.technologiesUsed; track tech) {
+ {{ tech }}
+ }
+
+
+ }
+
+ @if (project.startDate || project.endDate) {
+
+
Duration
+
+ @if (project.startDate) {
+ {{ project.startDate | date: 'MMM yyyy' }}
+ }
+ @if (project.startDate && project.endDate) {
+ —
+ }
+ @if (project.endDate) {
+ {{ project.endDate | date: 'MMM yyyy' }}
+ }
+
+
+ }
+
+ @if (project.challenges) {
+
+
Challenges
+
{{ project.challenges }}
+
+ }
+
+ @if (project.lessonsLearned) {
+
+
Lessons Learned
+
{{ project.lessonsLearned }}
+
+ }
+
+ @if (project.impact) {
+
+
Impact
+
{{ project.impact }}
+
+ }
+
+ @if (project.categories?.length) {
+
+
Categories
+
+ @for (cat of project.categories; track cat) {
+ {{ cat }}
+ }
+
+
+ }
+
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 8e3af42..236ac2f 100644
--- a/src/app/admin/projects/projects.html
+++ b/src/app/admin/projects/projects.html
@@ -2,6 +2,9 @@
@@ -12,7 +15,7 @@
- @for (category of model.projectsCategories; track category) {
+ @for (category of projectsCategories; track category) {
@@ -40,7 +43,7 @@
- @for (category of model.projectsCategories; track category) {
+ @for (category of projectsCategories; track category) {
@@ -56,9 +59,9 @@
-
-
-
+
@@ -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 ef5236b..c18f169 100644
--- a/src/app/admin/projects/projects.ts
+++ b/src/app/admin/projects/projects.ts
@@ -1,47 +1,223 @@
-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 { MatDialog, MatDialogModule } from '@angular/material/dialog';
+import { DynamicPopupComponent } from '../../shared/dynamic-popup/dynamic-popup';
+import { DynamicFormConfig } from '../../shared/dynamic-form/dynamic-form-config';
+import { ProjectDetailPopupComponent } from './project-detail-popup/project-detail-popup';
+import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog';
+import { AdminQuery } from '../state/admin.query';
+import { AdminStateService } from '../state/admin-state.service';
+import { AdminService } from '../services/admin.service';
+import { 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 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 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 {
- this.getProjects();
- }
+ this.projects$
+ .pipe(
+ filter(data => data != null),
+ takeUntil(this.destroy$)
+ )
+ .subscribe(data => {
+ this.projects = data.projects;
+ this.projectsCategories = data.projectsCategories;
+ });
- getProjects() {
- this.svc.getProjects(this.candidateId).subscribe((response: IProjects) => {
- this.svc.projects = this.svc.projects ?? response;
- this.projects = response.projects;
- this.assignData(response);
- });
+ // Only fetch if not already in store
+ if (!this.adminQuery.getProjects()) {
+ this.adminState.loadProjects()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe();
+ }
}
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)
+ );
+ }
+
+ 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 {
+ const ref = this.dialog.open(ConfirmDialogComponent, {
+ data: {
+ title: 'Delete Project',
+ message: `Are you sure you want to delete "${project.name}"? This action cannot be undone.`
+ },
+ panelClass: 'dark-popup-panel',
+ width: '420px'
+ });
+
+ ref.afterClosed()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((confirmed: boolean) => {
+ if (!confirmed) return;
+
+ this.adminService.deleteProject(project.projectId)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(() => {
+ const allProjects = this.adminQuery.getProjects();
+ const updatedList = (allProjects?.projects ?? []).filter(p => p.projectId !== project.projectId);
+ const categories = [...new Set(updatedList.flatMap(p => p.categories ?? []))];
+
+ this.adminState.updateProjects({
+ projects: updatedList,
+ projectsCategories: categories.length ? categories : []
+ });
+ this.filter = 'All';
+ });
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
}
}
diff --git a/src/app/admin/resume/resume.html b/src/app/admin/resume/resume.html
index 871ab1b..6207307 100644
--- a/src/app/admin/resume/resume.html
+++ b/src/app/admin/resume/resume.html
@@ -1,3 +1,4 @@
+@if (resume$ | async; as model) {
@@ -12,11 +13,15 @@
Education
+
+
- @for (education of model.academics; track education) {
+ @for (education of model.academics; track education.academicId) {
-
@@ -45,11 +50,15 @@
Experience
+
+
- @for (experience of model.experiences; track experience) {
+ @for (experience of model.experiences; track experience.experienceId) {
-
@@ -71,11 +80,17 @@
-
My skills
+
+
My skills
+
+
+
- @for (skill of model.skills; track skill) {
+ @for (skill of model.skills; track skill.skillId) {
-
@@ -96,4 +111,43 @@
-
\ No newline at end of file
+
+
+
+
+
+
+
+
Certifications
+
+
+
+
+
+
+ @for (cert of model.certifications; track cert.certificationId) {
+
+ -
+
+
{{cert.certificationName}}
+
+ {{cert.issuingOrganization}}
+
+ @if (cert.certificationLink) {
+
+ View Certificate
+
+ }
+
+
+
+ }
+
+
+
+
+
+
+}
\ No newline at end of file
diff --git a/src/app/admin/resume/resume.model.ts b/src/app/admin/resume/resume.model.ts
index 46252f7..fdbb93b 100644
--- a/src/app/admin/resume/resume.model.ts
+++ b/src/app/admin/resume/resume.model.ts
@@ -1,4 +1,5 @@
import { IAcademic } from "../models/academic.model";
+import { ICertification } from "../models/certification.model";
import { IExperience } from "../models/experience.model";
import { ISkill } from "../models/skill.model";
@@ -6,4 +7,5 @@ export interface IResume{
academics?: IAcademic[];
experiences?: IExperience[];
skills?: ISkill[];
+ certifications?: ICertification[];
}
\ No newline at end of file
diff --git a/src/app/admin/resume/resume.scss b/src/app/admin/resume/resume.scss
index e69de29..b862ea3 100644
--- a/src/app/admin/resume/resume.scss
+++ b/src/app/admin/resume/resume.scss
@@ -0,0 +1,41 @@
+.title-wrapper {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.edit-btn {
+ background: transparent;
+ border: none;
+ padding: 6px 8px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 1rem;
+ color: var(--orange-yellow-crayola);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ align-self: flex-start;
+ margin-top: -4px;
+ transition: background 0.2s ease;
+}
+
+.edit-btn:hover {
+ background: rgba(227, 179, 65, 0.12);
+}
+
+.timeline-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--orange-yellow-crayola);
+ font-size: var(--fs-6);
+ margin-top: 4px;
+ text-decoration: none;
+ transition: color 0.2s ease;
+
+ &:hover {
+ color: var(--vegas-gold);
+ text-decoration: underline;
+ }
+}
diff --git a/src/app/admin/resume/resume.ts b/src/app/admin/resume/resume.ts
index d6d3df9..748c36f 100644
--- a/src/app/admin/resume/resume.ts
+++ b/src/app/admin/resume/resume.ts
@@ -1,39 +1,216 @@
-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();
+ if (!this.adminQuery.getResume()) {
+ this.adminState.loadResume()
+ .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 config: DynamicFormConfig = {
+ title: 'Edit Education',
+ submitLabel: 'Save',
+ api: {
+ save: '/api/v1/admin/UpsertAcademics',
+ method: 'POST',
+ bodyKey: 'academics'
+ },
+ fields: [
+ {
+ name: 'academics',
+ label: 'Education',
+ type: 'array',
+ itemConfig: [
+ { name: 'academicId', label: 'ID', type: 'hidden' },
+ { name: 'degree', label: 'Degree', type: 'text', required: true },
+ { name: 'degreeSpecialization', label: 'Specialization', type: 'text' },
+ { name: 'institution', label: 'Institution', type: 'text', required: true },
+ { name: 'startYear', label: 'Start Year', type: 'year' },
+ { name: 'endYear', label: 'End Year', type: 'year' }
+ ]
+ }
+ ]
+ };
+
+ const data = { academics: resume?.academics ?? [] };
+
+ const ref = this.dialog.open(DynamicPopupComponent, {
+ data: { config, data },
+ panelClass: 'dark-popup-panel'
});
+
+ ref.afterClosed()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((res: IAcademic[] | null) => {
+ if (res) {
+ this.adminState.updateResume({ ...resume!, academics: res });
+ }
+ });
+ }
+
+ openEditExperience(): void {
+ const resume = this.adminQuery.getResume();
+
+ const config: DynamicFormConfig = {
+ title: 'Edit Experience',
+ submitLabel: 'Save',
+ api: {
+ save: '/api/v1/admin/UpsertExperiences',
+ method: 'POST',
+ bodyKey: 'experiences'
+ },
+ fields: [
+ {
+ name: 'experiences',
+ label: 'Experiences',
+ type: 'array',
+ itemConfig: [
+ { name: 'experienceId', label: 'ID', type: 'hidden' },
+ { name: 'title', label: 'Job Title', type: 'text', required: true },
+ { name: 'company', label: 'Company', type: 'text', required: true },
+ { name: 'location', label: 'Location', type: 'text' },
+ { name: 'description', label: 'Description', type: 'textarea' },
+ { name: 'startDate', label: 'Start Date', type: 'date', required: true },
+ { name: 'endDate', label: 'End Date', type: 'date' }
+ ]
+ }
+ ]
+ };
+
+ const data = { experiences: resume?.experiences ?? [] };
+
+ const ref = this.dialog.open(DynamicPopupComponent, {
+ data: { config, data },
+ panelClass: 'dark-popup-panel'
+ });
+
+ ref.afterClosed()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((res: IExperience[] | null) => {
+ if (res) {
+ this.adminState.updateResume({ ...resume!, experiences: res });
+ }
+ });
+ }
+
+ openEditSkills(): void {
+ const resume = this.adminQuery.getResume();
+
+ const config: DynamicFormConfig = {
+ title: 'Edit Skills',
+ submitLabel: 'Save',
+ api: {
+ save: '/api/v1/admin/UpsertSkills',
+ method: 'POST',
+ bodyKey: 'skills'
+ },
+ fields: [
+ {
+ name: 'skills',
+ label: 'Skills',
+ type: 'array',
+ itemConfig: [
+ { name: 'skillId', label: 'ID', type: 'hidden' },
+ { name: 'name', label: 'Skill Name', type: 'text', required: true },
+ { name: 'description', label: 'Description', type: 'text' },
+ { name: 'proficiencyLevel', label: 'Proficiency (%)', type: 'number', required: true }
+ ]
+ }
+ ]
+ };
+
+ const data = { skills: resume?.skills ?? [] };
+
+ const ref = this.dialog.open(DynamicPopupComponent, {
+ data: { config, data },
+ panelClass: 'dark-popup-panel'
+ });
+
+ ref.afterClosed()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((res: ISkill[] | null) => {
+ if (res) {
+ this.adminState.updateResume({ ...resume!, skills: res });
+ }
+ });
+ }
+
+ openEditCertifications(): void {
+ const resume = this.adminQuery.getResume();
+
+ const config: DynamicFormConfig = {
+ title: 'Edit Certifications',
+ submitLabel: 'Save',
+ api: {
+ save: '/api/v1/admin/UpsertCertifications',
+ method: 'POST',
+ bodyKey: 'certifications'
+ },
+ fields: [
+ {
+ name: 'certifications',
+ label: 'Certifications',
+ type: 'array',
+ itemConfig: [
+ { name: 'certificationId', label: 'ID', type: 'hidden' },
+ { name: 'certificationName', label: 'Certification Name', type: 'text', required: true },
+ { name: 'issuingOrganization', label: 'Issuing Organization', type: 'text', required: true },
+ { name: 'certificationLink', label: 'Link', type: 'text', placeholder: 'https://...' },
+ { name: 'issueDate', label: 'Issue Date', type: 'date' },
+ { name: 'expiryDate', label: 'Expiry Date', type: 'date' }
+ ]
+ }
+ ]
+ };
+
+ const data = { certifications: resume?.certifications ?? [] };
+
+ const ref = this.dialog.open(DynamicPopupComponent, {
+ data: { config, data },
+ panelClass: 'dark-popup-panel'
+ });
+
+ ref.afterClosed()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((res: ICertification[] | null) => {
+ if (res) {
+ this.adminState.updateResume({ ...resume!, certifications: res });
+ }
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
}
}
diff --git a/src/app/admin/services/admin.service.ts b/src/app/admin/services/admin.service.ts
index 9d23dd0..8b82b88 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);
- }
- 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{
- if(this.candidateAndSocialLinks != null){
- return of(this.candidateAndSocialLinks);
- }
- 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{
- if(this.resume != null){
- return of(this.resume);
- }
- 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{
- if(this.projects != null){
- return of(this.projects);
- }
- 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);
+ }
+
+ deleteProject(projectId: number): Observable {
+ return this.http.delete(this.api(`/api/v1/admin/DeleteProject/${projectId}`));
+ }
+
+ 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
new file mode 100644
index 0000000..ea62f3c
--- /dev/null
+++ b/src/app/admin/state/admin-state.service.ts
@@ -0,0 +1,140 @@
+import { Injectable, inject } from '@angular/core';
+import { tap } from 'rxjs';
+import { AdminStore, AdminSection } from './admin.store';
+import { AdminService } from '../services/admin.service';
+import { IAbout } from '../about/about.model';
+import { IHobby } from '../models/hobby.model';
+import { IContactModel } from '../contact/contact.model';
+import { IProjects } from '../projects/projects.model';
+import { IResume } from '../resume/resume.model';
+
+@Injectable({ providedIn: 'root' })
+export class AdminStateService {
+ private store = inject(AdminStore);
+ private api = inject(AdminService);
+
+ // ── About ──────────────────────────────────────────────
+
+ loadAbout() {
+ this.store.setSectionLoading('about', true);
+ return this.api.getHobbies().pipe(
+ tap({
+ next: (about: IAbout) => {
+ this.store.update({ about });
+ this.store.setSectionLoading('about', false);
+ this.store.setSectionError('about', null);
+ },
+ error: (err) => {
+ this.store.setSectionLoading('about', false);
+ this.store.setSectionError('about', err.message);
+ }
+ })
+ );
+ }
+
+ updateAbout(about: IAbout) {
+ this.store.update({ about });
+ }
+
+ upsertHobbies(hobbies: IHobby[]) {
+ this.store.setSectionLoading('about', true);
+ return this.api.upsertHobbies(hobbies).pipe(
+ tap({
+ next: (updatedHobbies: IHobby[]) => {
+ this.store.update(state => ({
+ about: state.about ? { ...state.about, hobbies: updatedHobbies } : state.about
+ }));
+ this.store.setSectionLoading('about', false);
+ this.store.setSectionError('about', null);
+ },
+ error: (err) => {
+ this.store.setSectionLoading('about', false);
+ this.store.setSectionError('about', err.message);
+ }
+ })
+ );
+ }
+
+ // ── Contact ────────────────────────────────────────────
+
+ loadContact() {
+ this.store.setSectionLoading('contact', true);
+ return this.api.getCandidateWithSocialLinks().pipe(
+ tap({
+ next: (contact: IContactModel) => {
+ this.store.update({ contact });
+ this.store.setSectionLoading('contact', false);
+ this.store.setSectionError('contact', null);
+ },
+ error: (err) => {
+ this.store.setSectionLoading('contact', false);
+ this.store.setSectionError('contact', err.message);
+ }
+ })
+ );
+ }
+
+ updateContact(contact: IContactModel) {
+ this.store.update({ contact });
+ }
+
+ // ── Projects ───────────────────────────────────────────
+
+ loadProjects() {
+ this.store.setSectionLoading('projects', true);
+ return this.api.getProjects().pipe(
+ tap({
+ next: (projects: IProjects) => {
+ this.store.update({ projects });
+ this.store.setSectionLoading('projects', false);
+ this.store.setSectionError('projects', null);
+ },
+ error: (err) => {
+ this.store.setSectionLoading('projects', false);
+ this.store.setSectionError('projects', err.message);
+ }
+ })
+ );
+ }
+
+ updateProjects(projects: IProjects) {
+ this.store.update({ projects });
+ }
+
+ // ── Resume ─────────────────────────────────────────────
+
+ loadResume() {
+ this.store.setSectionLoading('resume', true);
+ return this.api.getResume().pipe(
+ tap({
+ next: (resume: IResume) => {
+ this.store.update({ resume });
+ this.store.setSectionLoading('resume', false);
+ this.store.setSectionError('resume', null);
+ },
+ error: (err) => {
+ this.store.setSectionLoading('resume', false);
+ this.store.setSectionError('resume', err.message);
+ }
+ })
+ );
+ }
+
+ updateResume(resume: IResume) {
+ this.store.update({ resume });
+ }
+
+ // ── Reset helpers ──────────────────────────────────────
+
+ resetSection(section: AdminSection) {
+ this.store.update(state => ({
+ [section]: null,
+ loading: { ...state.loading, [section]: false },
+ error: { ...state.error, [section]: null }
+ }));
+ }
+
+ resetAll() {
+ this.store.reset();
+ }
+}
diff --git a/src/app/admin/state/admin.query.ts b/src/app/admin/state/admin.query.ts
new file mode 100644
index 0000000..1e18205
--- /dev/null
+++ b/src/app/admin/state/admin.query.ts
@@ -0,0 +1,48 @@
+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));
+ }
+
+ // 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;
+ }
+}
diff --git a/src/app/admin/state/admin.store.ts b/src/app/admin/state/admin.store.ts
new file mode 100644
index 0000000..19a7197
--- /dev/null
+++ b/src/app/admin/state/admin.store.ts
@@ -0,0 +1,48 @@
+import { Injectable } from '@angular/core';
+import { Store, StoreConfig } from '@datorama/akita';
+import { IAbout } from '../about/about.model';
+import { IContactModel } from '../contact/contact.model';
+import { IProjects } from '../projects/projects.model';
+import { IResume } from '../resume/resume.model';
+
+export type AdminSection = 'about' | 'contact' | 'projects' | 'resume';
+
+export interface AdminState {
+ about: IAbout | null;
+ contact: IContactModel | null;
+ projects: IProjects | null;
+ resume: IResume | null;
+ loading: Record;
+ error: Record;
+}
+
+export function createInitialState(): AdminState {
+ return {
+ about: null,
+ contact: null,
+ projects: null,
+ resume: null,
+ loading: { about: false, contact: false, projects: false, resume: false },
+ error: { about: null, contact: null, projects: null, resume: null }
+ };
+}
+
+@Injectable({ providedIn: 'root' })
+@StoreConfig({ name: 'admin', resettable: true })
+export class AdminStore extends Store {
+ 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.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/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..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';
@@ -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 {
@@ -121,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 @@