Merge pull request 'develop' (#8) from develop into master

Reviewed-on: #8
This commit is contained in:
rajukottedi 2026-02-16 10:40:47 +05:30
commit 9065626d40
141 changed files with 8766 additions and 236 deletions

View File

@ -2,7 +2,10 @@
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
"packageManager": "npm",
"schematicCollections": [
"angular-eslint"
]
},
"newProjectRoot": "projects",
"projects": {
@ -20,6 +23,7 @@
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": "dist/portfolio-admin",
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
@ -31,12 +35,7 @@
],
"styles": [
"src/styles.scss"
],
"server": "src/main.server.ts",
"outputMode": "server",
"ssr": {
"entry": "src/server.ts"
}
]
},
"configurations": {
"production": {
@ -92,6 +91,15 @@
"src/styles.scss"
]
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}

43
eslint.config.js Normal file
View File

@ -0,0 +1,43 @@
// @ts-check
const eslint = require("@eslint/js");
const tseslint = require("typescript-eslint");
const angular = require("angular-eslint");
module.exports = tseslint.config(
{
files: ["**/*.ts"],
extends: [
eslint.configs.recommended,
...tseslint.configs.recommended,
...tseslint.configs.stylistic,
...angular.configs.tsRecommended,
],
processor: angular.processInlineTemplates,
rules: {
"@angular-eslint/directive-selector": [
"error",
{
type: "attribute",
prefix: "app",
style: "camelCase",
},
],
"@angular-eslint/component-selector": [
"error",
{
type: "element",
prefix: "app",
style: "kebab-case",
},
],
},
},
{
files: ["**/*.html"],
extends: [
...angular.configs.templateRecommended,
...angular.configs.templateAccessibility,
],
rules: {},
}
);

2195
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,8 @@
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"serve:ssr:portfolio-admin": "node dist/portfolio-admin/server/server.mjs"
"serve:ssr:portfolio-admin": "node dist/portfolio-admin/server/server.mjs",
"lint": "ng lint"
},
"prettier": {
"printWidth": 100,
@ -23,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",
@ -33,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"
@ -44,12 +47,15 @@
"@types/express": "^5.0.1",
"@types/jasmine": "~5.1.0",
"@types/node": "^20.17.19",
"angular-eslint": "20.6.0",
"eslint": "^9.39.0",
"jasmine-core": "~5.9.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.9.2"
"typescript": "~5.9.2",
"typescript-eslint": "8.46.3"
}
}
}

0
public/assets/.gitkeep Normal file
View File

12
public/assets/css/all.min.css vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

159
public/assets/js/script.js Normal file
View File

@ -0,0 +1,159 @@
'use strict';
// element toggle function
const elementToggleFunc = function (elem) { elem.classList.toggle("active"); }
// sidebar variables
const sidebar = document.querySelector("[data-sidebar]");
const sidebarBtn = document.querySelector("[data-sidebar-btn]");
// sidebar toggle functionality for mobile
sidebarBtn.addEventListener("click", function () { elementToggleFunc(sidebar); });
// testimonials variables
const testimonialsItem = document.querySelectorAll("[data-testimonials-item]");
const modalContainer = document.querySelector("[data-modal-container]");
const modalCloseBtn = document.querySelector("[data-modal-close-btn]");
const overlay = document.querySelector("[data-overlay]");
// modal variable
const modalImg = document.querySelector("[data-modal-img]");
const modalTitle = document.querySelector("[data-modal-title]");
const modalText = document.querySelector("[data-modal-text]");
// modal toggle function
const testimonialsModalFunc = function () {
modalContainer.classList.toggle("active");
overlay.classList.toggle("active");
}
// add click event to all modal items
for (let i = 0; i < testimonialsItem.length; i++) {
testimonialsItem[i].addEventListener("click", function () {
modalImg.src = this.querySelector("[data-testimonials-avatar]").src;
modalImg.alt = this.querySelector("[data-testimonials-avatar]").alt;
modalTitle.innerHTML = this.querySelector("[data-testimonials-title]").innerHTML;
modalText.innerHTML = this.querySelector("[data-testimonials-text]").innerHTML;
testimonialsModalFunc();
});
}
// add click event to modal close button
modalCloseBtn.addEventListener("click", testimonialsModalFunc);
overlay.addEventListener("click", testimonialsModalFunc);
// custom select variables
const select = document.querySelector("[data-select]");
const selectItems = document.querySelectorAll("[data-select-item]");
const selectValue = document.querySelector("[data-selecct-value]");
const filterBtn = document.querySelectorAll("[data-filter-btn]");
select.addEventListener("click", function () { elementToggleFunc(this); });
// add event in all select items
for (let i = 0; i < selectItems.length; i++) {
selectItems[i].addEventListener("click", function () {
let selectedValue = this.innerText.toLowerCase();
selectValue.innerText = this.innerText;
elementToggleFunc(select);
filterFunc(selectedValue);
});
}
// filter variables
const filterItems = document.querySelectorAll("[data-filter-item]");
const filterFunc = function (selectedValue) {
for (let i = 0; i < filterItems.length; i++) {
if (selectedValue === "all") {
filterItems[i].classList.add("active");
} else if (selectedValue === filterItems[i].dataset.category) {
filterItems[i].classList.add("active");
} else {
filterItems[i].classList.remove("active");
}
}
}
// add event in all filter button items for large screen
let lastClickedBtn = filterBtn[0];
for (let i = 0; i < filterBtn.length; i++) {
filterBtn[i].addEventListener("click", function () {
let selectedValue = this.innerText.toLowerCase();
selectValue.innerText = this.innerText;
filterFunc(selectedValue);
lastClickedBtn.classList.remove("active");
this.classList.add("active");
lastClickedBtn = this;
});
}
// contact form variables
const form = document.querySelector("[data-form]");
const formInputs = document.querySelectorAll("[data-form-input]");
const formBtn = document.querySelector("[data-form-btn]");
// add event to all form input field
for (let i = 0; i < formInputs.length; i++) {
formInputs[i].addEventListener("input", function () {
// check form validation
if (form.checkValidity()) {
formBtn.removeAttribute("disabled");
} else {
formBtn.setAttribute("disabled", "");
}
});
}
// page navigation variables
const navigationLinks = document.querySelectorAll("[data-nav-link]");
const pages = document.querySelectorAll("[data-page]");
// add event to all nav link
for (let i = 0; i < navigationLinks.length; i++) {
navigationLinks[i].addEventListener("click", function () {
for (let i = 0; i < pages.length; i++) {
if (this.innerHTML.toLowerCase() === pages[i].dataset.page) {
pages[i].classList.add("active");
navigationLinks[i].classList.add("active");
window.scrollTo(0, 0);
} else {
pages[i].classList.remove("active");
navigationLinks[i].classList.remove("active");
}
}
});
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

17
public/favicon.svg Normal file
View File

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="bg" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="3%" style="stop-color:hsl(240,1%,25%)"/>
<stop offset="97%" style="stop-color:hsl(0,0%,19%)"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#bg)"/>
<!-- Crown -->
<path d="M10 44 L10 24 L22 34 L32 16 L42 34 L54 24 L54 44 Z"
fill="hsl(45,100%,72%)" stroke="hsl(45,100%,72%)" stroke-width="1" stroke-linejoin="round"/>
<rect x="10" y="44" width="44" height="6" rx="2" fill="hsl(45,100%,72%)"/>
<!-- jewels -->
<circle cx="22" cy="38" r="2.5" fill="hsl(240,1%,17%)"/>
<circle cx="32" cy="35" r="2.5" fill="hsl(240,1%,17%)"/>
<circle cx="42" cy="38" r="2.5" fill="hsl(240,1%,17%)"/>
</svg>

After

Width:  |  Height:  |  Size: 805 B

View File

@ -0,0 +1,41 @@
@if (about$ | async; as model) {
<article class="about active" data-page="about">
<header>
<h2 class="h2 article-title">About Me</h2>
<button class="edit-btn" (click)="openEdit()" aria-label="Edit About">
<i class="fa-light fa-pencil"></i>
</button>
</header>
<section class="about-text">
<pre class="text-style">
{{model.about}}
</pre>
</section>
<section class="service">
<h3 class="h3 service-title">What i'm doing</h3>
<ul class="service-list">
@for (hobby of model.hobbies; track hobby.hobbyId) {
<li class="service-item">
<div class="service-icon-box">
<i class="fa-regular fa-2x " [ngClass]="hobby.icon"></i>
</div>
<div class="service-content-box">
<h4 class="h4 service-item-title">{{hobby.name}}</h4>
<p class="service-item-text">
{{hobby.description}}
</p>
</div>
</li>
}
</ul>
</section>
</article>
}

View File

@ -0,0 +1,7 @@
import { IHobby } from "../models/hobby.model";
export interface IAbout{
about: string;
title: string;
hobbies: IHobby[];
}

View File

@ -0,0 +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);
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { About } from './about';
describe('About', () => {
let component: About;
let fixture: ComponentFixture<About>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [About]
})
.compileComponents();
fixture = TestBed.createComponent(About);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,89 @@
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
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, MatDialogModule],
templateUrl: './about.html',
styleUrl: './about.scss'
})
export class About implements OnInit, OnDestroy {
private dialog = inject(MatDialog);
private adminQuery = inject(AdminQuery);
private adminState = inject(AdminStateService);
private destroy$ = new Subject<void>();
about$ = this.adminQuery.about$;
loading$ = this.adminQuery.aboutLoading$;
imagesOrigin = environment.apiUrl + '/images/';
popupConfig?: DynamicFormConfig;
popupData: Record<string, unknown> = {};
ngOnInit(): void {
if (!this.adminQuery.getAbout()) {
this.adminState.loadAbout()
.pipe(takeUntil(this.destroy$))
.subscribe();
}
}
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();
}
}

View File

@ -0,0 +1,132 @@
@if (contact$ | async; as model) {
<aside class="sidebar" [ngClass]="sideBarExpanded ? 'active' : ''" data-sidebar>
<div class="sidebar-info">
<figure class="avatar-box">
<img src="./assets/images/my-pic.png" alt="{{model.candidate?.displayName}}" width="80">
</figure>
<div class="info-content">
<div class="name-row">
<h1 class="name" title="{{model.candidate?.displayName}}">{{model.candidate?.displayName}}</h1>
<button class="edit-btn" (click)="openEdit()" aria-label="Edit Contact">
<i class="fa-light fa-pencil"></i>
</button>
</div>
<p class="title">{{model.title}}</p>
</div>
<button class="info_more-btn" (click)="sideBarExpanded = !sideBarExpanded" data-sidebar-btn>
<span>Show Contacts</span>
<i class="fa-regular fa-chevron-down"></i>
</button>
</div>
<div class="sidebar-info_more">
<div class="separator"></div>
<ul class="contacts-list">
<li class="contact-item">
<div class="icon-box">
<i class="fa-light fa-envelope"></i>
</div>
<div class="contact-info">
<p class="contact-title">Email</p>
<a href="mailto:{{model.candidate?.email}}" class="contact-link">{{model.candidate?.email}}</a>
</div>
</li>
<li class="contact-item">
<div class="icon-box">
<i class="fa-light fa-mobile-notch"></i>
</div>
<div class="contact-info">
<p class="contact-title">Phone</p>
<a href="tel:{{model.candidate?.phone}}" class="contact-link">{{model.candidate?.phone}}</a>
</div>
</li>
<li class="contact-item">
<div class="icon-box">
<i class="fa-regular fa-cake-candles"></i>
</div>
<div class="contact-info">
<p class="contact-title">Birthday</p>
<time>{{model.candidate?.dob}}</time>
</div>
</li>
<li class="contact-item">
<div class="icon-box">
<i class="fa-light fa-location-dot"></i>
</div>
<div class="contact-info">
<p class="contact-title">Location</p>
<address>{{model.candidate?.address}}</address>
</div>
</li>
</ul>
<div class="separator"></div>
<ul class="social-list">
@if (model.socialLinks?.linkedin) {
<li class="social-item">
<a href="{{model.socialLinks?.linkedin}}" target="_blank" class="social-link">
<i class="fa-brands fa-linkedin"></i>
</a>
</li>
}
@if (model.socialLinks?.gitHub) {
<li class="social-item">
<a href="{{model.socialLinks?.gitHub}}" target="_blank" class="social-link">
<i class="fa-brands fa-github"></i>
</a>
</li>
}
@if (model.socialLinks?.blogUrl) {
<li class="social-item">
<a href="{{model.socialLinks?.blogUrl}}" target="_blank" class="social-link">
<i class="fa-duotone fa-blog"></i>
</a>
</li>
}
</ul>
</div>
<hr class="logout-divider" />
<div class="logout-section">
<button class="logout-btn" (click)="logout()">
<span class="icon">🔓</span>
<span>Logout</span>
</button>
</div>
</aside>
}

View File

@ -0,0 +1,8 @@
import { ICandidate } from "../models/candidate.model";
import { ISocialLinks } from "../models/social-links.model";
export interface IContactModel {
title: string;
candidate?: ICandidate;
socialLinks?: ISocialLinks;
}

View File

@ -0,0 +1,86 @@
img {
border-radius: inherit;
}
.logout-section {
margin-top: 14px;
padding-bottom: 8px;
display: flex;
justify-content: center;
}
.logout-btn {
width: 90%;
margin: 0 auto;
padding: 14px 18px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
background: #191919; // darker for better contrast
border: 1px solid rgba(227, 179, 65, 0.18); // subtle gold border
border-radius: 14px;
cursor: pointer;
color: #e3b341; // gold text
font-size: 15px;
font-weight: 500;
transition: 0.3s ease;
/* Subtle depth */
box-shadow:
inset 0 0 8px rgba(255, 255, 255, 0.03),
0 4px 18px rgba(0, 0, 0, 0.45);
&:hover {
background: rgba(227, 179, 65, 0.12);
border-color: rgba(227, 179, 65, 0.35);
box-shadow:
0 0 12px rgba(227, 179, 65, 0.25),
inset 0 0 10px rgba(227, 179, 65, 0.1);
color: #fff;
}
}
.icon {
font-size: 18px;
line-height: 0;
}
.logout-divider {
border: 0;
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);
}
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Contact } from './contact';
describe('Contact', () => {
let component: Contact;
let fixture: ComponentFixture<Contact>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Contact]
})
.compileComponents();
fixture = TestBed.createComponent(Contact);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,121 @@
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, MatDialogModule],
templateUrl: './contact.html',
styleUrl: './contact.scss'
})
export class Contact implements OnInit, OnDestroy {
sideBarExpanded = false;
authSvc = inject(AuthService);
private dialog = inject(MatDialog);
private adminQuery = inject(AdminQuery);
private adminState = inject(AdminStateService);
private destroy$ = new Subject<void>();
contact$ = this.adminQuery.contact$;
loading$ = this.adminQuery.contactLoading$;
imagesOrigin = environment.apiUrl + '/images/';
popupConfig?: DynamicFormConfig;
popupData: Record<string, unknown> = {};
ngOnInit(): void {
if (!this.adminQuery.getContact()) {
this.adminState.loadContact()
.pipe(takeUntil(this.destroy$))
.subscribe();
}
}
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();
}
}

View File

@ -0,0 +1,9 @@
export interface IAcademic{
academicId: number;
institution: string;
startYear: number;
endYear: number;
degree: string;
period: string;
degreeSpecialization: string;
}

View File

@ -0,0 +1,10 @@
export interface ICandidate{
firstName: string;
lastName: string;
dob: string;
email: string;
phone: string;
address: string;
avatar: string;
displayName: string;
}

View File

@ -0,0 +1,8 @@
export interface ICertification {
certificationId: number;
certificationName: string;
issuingOrganization: string;
certificationLink: string;
issueDate: string;
expiryDate?: string;
}

View File

@ -0,0 +1,21 @@
import { IAcademic } from "./academic.model";
import { ICandidate } from "./candidate.model";
import { IExperience } from "./experience.model";
import { IHobby } from "./hobby.model";
import { IProject } from "./project.model";
import { ISkill } from "./skill.model";
import { ISocialLinks } from "./social-links.model";
export interface ICv{
resumeId: number;
title: string;
about: string;
candidate: ICandidate;
socialLinks: ISocialLinks;
academics: IAcademic[];
skills: ISkill[];
experiences: IExperience[];
hobbies: IHobby[];
projects: IProject[];
projectsCategories: string[];
}

View File

@ -0,0 +1,5 @@
export interface IExperienceDetails{
id: number;
details: string;
order: number;
}

View File

@ -0,0 +1,15 @@
import { IExperienceDetails } from "./experience-details.model";
export interface IExperience{
experienceId: number;
title: string;
description: string;
company: string;
startYear: string;
endYear: string;
period: string;
location: string;
startDate: string | null;
endDate: string | null;
details: IExperienceDetails[];
}

View File

@ -0,0 +1,6 @@
export interface IHobby{
hobbyId: number;
name: string;
description: string;
icon: string;
}

View File

@ -0,0 +1,17 @@
export interface IProject{
projectId: number;
name: string;
description: string;
categories: string[];
roles: string[];
responsibilities: string[];
technologiesUsed: string[];
imagePath: string;
challenges: string;
lessonsLearned: string;
impact: string;
startDate: string;
endDate: string;
status: string;
resumeId: number;
}

View File

@ -0,0 +1,6 @@
export interface ISkill{
skillId: number;
name: string;
description: string;
proficiencyLevel: number;
}

View File

@ -0,0 +1,6 @@
export interface ISocialLinks{
id: number;
gitHub: string;
linkedin: string;
blogUrl: string;
}

View File

@ -0,0 +1,101 @@
<div class="project-detail" role="dialog" aria-labelledby="project-title">
<div class="detail-header">
<h2 id="project-title">{{ project.name }}</h2>
<button class="close-btn" (click)="close()" aria-label="Close">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
@if (project.status) {
<span class="status-badge">{{ project.status }}</span>
}
@if (project.description) {
<div class="detail-section">
<h4>Description</h4>
<p>{{ project.description }}</p>
</div>
}
@if (project.roles.length) {
<div class="detail-section">
<h4>Roles</h4>
<div class="tag-list">
@for (role of project.roles; track role) {
<span class="tag">{{ role }}</span>
}
</div>
</div>
}
@if (project.responsibilities.length) {
<div class="detail-section">
<h4>Responsibilities</h4>
<div class="tag-list">
@for (r of project.responsibilities; track r) {
<span class="tag">{{ r }}</span>
}
</div>
</div>
}
@if (project.technologiesUsed.length) {
<div class="detail-section">
<h4>Technologies</h4>
<div class="tag-list">
@for (tech of project.technologiesUsed; track tech) {
<span class="tag tech">{{ tech }}</span>
}
</div>
</div>
}
@if (project.startDate || project.endDate) {
<div class="detail-section">
<h4>Duration</h4>
<p class="duration">
@if (project.startDate) {
<span>{{ project.startDate | date: 'MMM yyyy' }}</span>
}
@if (project.startDate && project.endDate) {
<span></span>
}
@if (project.endDate) {
<span>{{ project.endDate | date: 'MMM yyyy' }}</span>
}
</p>
</div>
}
@if (project.challenges) {
<div class="detail-section">
<h4>Challenges</h4>
<p>{{ project.challenges }}</p>
</div>
}
@if (project.lessonsLearned) {
<div class="detail-section">
<h4>Lessons Learned</h4>
<p>{{ project.lessonsLearned }}</p>
</div>
}
@if (project.impact) {
<div class="detail-section">
<h4>Impact</h4>
<p>{{ project.impact }}</p>
</div>
}
@if (project.categories.length) {
<div class="detail-section">
<h4>Categories</h4>
<div class="tag-list">
@for (cat of project.categories; track cat) {
<span class="tag category">{{ cat }}</span>
}
</div>
</div>
}
</div>

View File

@ -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: 1.2px solid hsla(45, 100%, 72%, 0.45);
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);
}
}

View File

@ -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<ProjectDetailPopupComponent>);
project: IProject = inject(MAT_DIALOG_DATA);
close(): void {
this.dialogRef.close();
}
}

View File

@ -0,0 +1,89 @@
<article class="projects" data-page="projects">
<header>
<h2 class="h2 article-title">Projects</h2>
<button class="edit-btn add-btn" (click)="openAddProject()" title="Add Project">
<i class="fa-solid fa-plus"></i>
</button>
</header>
<section class="projects">
<ul class="filter-list">
<li class="filter-item">
<button [ngClass]="{active: filter === 'All'}" (click)="filterProjects('All')">All</button>
</li>
@for (category of projectsCategories; track category) {
<li class="filter-item">
<button (click)="filterProjects(category)"
[ngClass]="{active: filter === category}">{{category}}</button>
</li>
}
</ul>
<div class="filter-select-box" (click)="categoryClicked = !categoryClicked" tabindex="0"
(keyup.enter)="categoryClicked = !categoryClicked">
<button class="filter-select" [ngClass]="{active: categoryClicked}">
<div class="select-value">{{filter}}</div>
<div class="select-icon">
<i class="fa-regular fa-chevron-down"></i>
</div>
</button>
<ul class="select-list">
<li class="select-item">
<button (click)="filterProjects('All')">All</button>
</li>
@for (category of projectsCategories; track category) {
<li class="select-item">
<button (click)="filterProjects(category)">{{category}}</button>
</li>
}
</ul>
</div>
<ul class="project-list">
@for (project of projects; track project) {
<li class="project-item active">
<a>
<figure class="project-img">
<button class="project-item-icon-box" (click)="openViewProject(project)" (keyup.enter)="openViewProject(project)" title="View Project">
<i class="fa-regular fa-eye"></i>
</button>
<img src="{{imagesOrigin + project.imagePath}}" alt="{{project.name}}" loading="lazy">
</figure>
<h3 class="project-title">{{project.name}}</h3>
<div class="project-category">
@for (responsibility of project.responsibilities; track responsibility; let i = $index) {
<span class="inline">{{i > 0 ? ', ' + responsibility : responsibility}}</span>
}
</div>
</a>
<button class="edit-btn project-edit-btn" (click)="openEditProject(project)" title="Edit Project">
<i class="fa-solid fa-pen-to-square"></i>
</button>
<button class="edit-btn project-delete-btn" (click)="deleteProject(project)" title="Delete Project">
<i class="fa-solid fa-trash"></i>
</button>
</li>
}
</ul>
</section>
</article>

View File

@ -0,0 +1,6 @@
import { IProject } from "../models/project.model";
export interface IProjects{
projects: IProject[];
projectsCategories: string[];
}

View File

@ -0,0 +1,97 @@
.inline{
display: inline;
}
.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;
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Projects } from './projects';
describe('Projects', () => {
let component: Projects;
let fixture: ComponentFixture<Projects>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Projects]
})
.compileComponents();
fixture = TestBed.createComponent(Projects);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,223 @@
import { Component, CUSTOM_ELEMENTS_SCHEMA, inject, OnInit, OnDestroy } from '@angular/core';
import { IProject } from '../models/project.model';
import { CommonModule } from '@angular/common';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { DynamicPopupComponent } from '../../shared/dynamic-popup/dynamic-popup';
import { DynamicFormConfig } from '../../shared/dynamic-form/dynamic-form-config';
import { ProjectDetailPopupComponent } from './project-detail-popup/project-detail-popup';
import { 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, MatDialogModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class Projects implements OnInit, OnDestroy {
filter = 'All';
projects: IProject[] = [];
projectsCategories: string[] = [];
categoryClicked = false;
imagesOrigin = environment.apiUrl + '/images/';
private dialog = inject(MatDialog);
private http = inject(HttpClient);
private adminQuery = inject(AdminQuery);
private adminState = inject(AdminStateService);
private adminService = inject(AdminService);
private destroy$ = new Subject<void>();
projects$ = this.adminQuery.projects$;
loading$ = this.adminQuery.projectsLoading$;
ngOnInit(): void {
this.projects$
.pipe(
filter(data => data != null),
takeUntil(this.destroy$)
)
.subscribe(data => {
this.projects = data.projects;
this.projectsCategories = data.projectsCategories;
});
// Only fetch if not already in store
if (!this.adminQuery.getProjects()) {
this.adminState.loadProjects()
.pipe(takeUntil(this.destroy$))
.subscribe();
}
}
filterProjects(category: string) {
this.filter = 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<string, unknown> {
return {
...project,
categories: (project.categories ?? []).join(', '),
roles: (project.roles ?? []).join(', '),
responsibilities: (project.responsibilities ?? []).join(', '),
technologiesUsed: (project.technologiesUsed ?? []).join(', ')
};
}
private formDataToProject(formVal: Record<string, unknown>): IProject {
const toArray = (val: unknown): string[] => {
if (!val || typeof val !== 'string') return [];
return val.split(',').map(s => s.trim()).filter(s => s.length > 0);
};
return {
...formVal,
categories: toArray(formVal['categories']),
roles: toArray(formVal['roles']),
responsibilities: toArray(formVal['responsibilities']),
technologiesUsed: toArray(formVal['technologiesUsed'])
} as IProject;
}
private saveProject(project: IProject, isNew: boolean): void {
const url = `${environment.apiUrl}/api/v1/admin/UpsertProject`;
this.http.post<IProject>(url, project)
.pipe(takeUntil(this.destroy$))
.subscribe((res: IProject) => {
const allProjects = this.adminQuery.getProjects();
let updatedList: IProject[];
if (isNew) {
updatedList = [...(allProjects?.projects ?? []), res];
} else {
updatedList = (allProjects?.projects ?? []).map(p =>
p.projectId === res.projectId ? res : p
);
}
const categories = [...new Set(updatedList.flatMap(p => p.categories ?? []))];
this.adminState.updateProjects({
projects: updatedList,
projectsCategories: categories.length ? categories : allProjects?.projectsCategories ?? []
});
this.filter = 'All';
});
}
openViewProject(project: IProject): void {
this.dialog.open(ProjectDetailPopupComponent, {
data: project,
panelClass: 'dark-popup-panel',
width: '520px',
maxHeight: '85vh'
});
}
openEditProject(project: IProject): void {
const config = this.getProjectFormConfig('Edit Project');
const data = this.projectToFormData(project);
const ref = this.dialog.open(DynamicPopupComponent, {
data: { config, data },
panelClass: 'dark-popup-panel'
});
ref.afterClosed()
.pipe(takeUntil(this.destroy$))
.subscribe((res: Record<string, unknown> | null) => {
if (!res) return;
const updated = this.formDataToProject(res);
this.saveProject(updated, false);
});
}
openAddProject(): void {
const config = this.getProjectFormConfig('Add Project');
const data: Record<string, unknown> = { projectId: 0 };
const ref = this.dialog.open(DynamicPopupComponent, {
data: { config, data },
panelClass: 'dark-popup-panel'
});
ref.afterClosed()
.pipe(takeUntil(this.destroy$))
.subscribe((res: Record<string, unknown> | null) => {
if (!res) return;
const newProject = this.formDataToProject(res);
this.saveProject(newProject, true);
});
}
deleteProject(project: IProject): void {
const ref = this.dialog.open(ConfirmDialogComponent, {
data: {
title: 'Delete Project',
message: `Are you sure you want to delete "${project.name}"? This action cannot be undone.`
},
panelClass: 'dark-popup-panel',
width: '420px'
});
ref.afterClosed()
.pipe(takeUntil(this.destroy$))
.subscribe((confirmed: boolean) => {
if (!confirmed) return;
this.adminService.deleteProject(project.projectId)
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
const allProjects = this.adminQuery.getProjects();
const updatedList = (allProjects?.projects ?? []).filter(p => p.projectId !== project.projectId);
const categories = [...new Set(updatedList.flatMap(p => p.categories ?? []))];
this.adminState.updateProjects({
projects: updatedList,
projectsCategories: categories.length ? categories : []
});
this.filter = 'All';
});
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -0,0 +1,153 @@
@if (resume$ | async; as model) {
<article class="resume" data-page="resume">
<header>
<h2 class="h2 article-title">Resume</h2>
</header>
<section class="timeline">
<div class="title-wrapper">
<div class="icon-box">
<i class="fa-light fa-book-open"></i>
</div>
<h3 class="h3">Education</h3>
<button class="edit-btn" type="button" (click)="openEditEducation()" title="Edit Education">
<i class="fa-solid fa-pen-to-square"></i>
</button>
</div>
<ol class="timeline-list">
@for (education of model.academics; track education.academicId) {
<li class="timeline-item">
<h4 class="h4 timeline-item-title">{{education.degree}}{{education.degreeSpecialization !== null ? " - "
+ education.degreeSpecialization : ""}}</h4>
<span>{{education.period}}</span>
<p class="timeline-text">
{{education.institution}}
</p>
</li>
}
</ol>
</section>
<section class="timeline">
<div class="title-wrapper">
<div class="icon-box">
<i class="fa-light fa-briefcase"></i>
</div>
<h3 class="h3">Experience</h3>
<button class="edit-btn" type="button" (click)="openEditExperience()" title="Edit Experience">
<i class="fa-solid fa-pen-to-square"></i>
</button>
</div>
<ol class="timeline-list">
@for (experience of model.experiences; track experience.experienceId) {
<li class="timeline-item">
<h4 class="h4 timeline-item-title">{{experience.title}}</h4>
<span>{{experience.period}}</span>
<p class="timeline-text">
{{experience.company}}
</p>
</li>
}
</ol>
</section>
<section class="skill">
<div class="title-wrapper">
<h3 class="h3 skills-title">My skills</h3>
<button class="edit-btn" type="button" (click)="openEditSkills()" title="Edit Skills">
<i class="fa-solid fa-pen-to-square"></i>
</button>
</div>
<ul class="skills-list content-card">
@for (skill of model.skills; track skill.skillId) {
<li class="skills-item">
<div class="title-wrapper">
<h5 class="h5">{{skill.name}}</h5>
<data value="{{skill.proficiencyLevel}}">{{skill.proficiencyLevel}}%</data>
</div>
<div class="skill-progress-bg">
<div class="skill-progress-fill" [style]="'width: ' + skill.proficiencyLevel + '%'"></div>
</div>
</li>
}
</ul>
</section>
<section class="timeline">
<div class="title-wrapper">
<div class="icon-box">
<i class="fa-light fa-certificate"></i>
</div>
<h3 class="h3">Certifications</h3>
<button class="edit-btn" type="button" (click)="openEditCertifications()" title="Edit Certifications">
<i class="fa-solid fa-pen-to-square"></i>
</button>
</div>
<ol class="timeline-list">
@for (cert of model.certifications; track cert.certificationId) {
<li class="timeline-item">
<h4 class="h4 timeline-item-title">{{cert.certificationName}}</h4>
<span>{{cert.issuingOrganization}}</span>
@if (cert.certificationLink) {
<a class="timeline-link" [href]="cert.certificationLink" target="_blank" rel="noopener">
<i class="fa-solid fa-arrow-up-right-from-square"></i> View Certificate
</a>
}
</li>
}
</ol>
</section>
</article>
}

View File

@ -0,0 +1,11 @@
import { IAcademic } from "../models/academic.model";
import { ICertification } from "../models/certification.model";
import { IExperience } from "../models/experience.model";
import { ISkill } from "../models/skill.model";
export interface IResume{
academics?: IAcademic[];
experiences?: IExperience[];
skills?: ISkill[];
certifications?: ICertification[];
}

View File

@ -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;
}
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Resume } from './resume';
describe('Resume', () => {
let component: Resume;
let fixture: ComponentFixture<Resume>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Resume]
})
.compileComponents();
fixture = TestBed.createComponent(Resume);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,216 @@
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, MatDialogModule]
})
export class Resume implements OnInit, OnDestroy {
private dialog = inject(MatDialog);
private adminQuery = inject(AdminQuery);
private adminState = inject(AdminStateService);
private destroy$ = new Subject<void>();
resume$ = this.adminQuery.resume$;
loading$ = this.adminQuery.resumeLoading$;
ngOnInit(): void {
if (!this.adminQuery.getResume()) {
this.adminState.loadResume()
.pipe(takeUntil(this.destroy$))
.subscribe();
}
}
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();
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { AdminService } from './admin.service';
describe('AdminService', () => {
let service: AdminService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(AdminService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,50 @@
import { inject, Injectable } from '@angular/core';
import { IAbout } from '../about/about.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 } from 'rxjs';
import { environment } from '../../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class AdminService {
private readonly baseUrl = (environment.apiUrl ?? '').replace(/\/+$/, '');
private http = inject(HttpClient);
private api(path: string) {
return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
}
getHobbies(): Observable<IAbout> {
return this.http.get<IAbout>(this.api('/api/v1/admin/GetHobbies'));
}
getCandidateWithSocialLinks(): Observable<IContactModel> {
return this.http.get<IContactModel>(this.api('/api/v1/admin/GetCandidateWithSocialLinks'));
}
getResume(): Observable<IResume> {
return this.http.get<IResume>(this.api('/api/v1/admin/GetResume'));
}
getProjects(): Observable<IProjects> {
return this.http.get<IProjects>(this.api('/api/v1/admin/GetProjects'));
}
upsertProject(project: IProject): Observable<IProject> {
return this.http.post<IProject>(this.api('/api/v1/admin/UpsertProject'), project);
}
deleteProject(projectId: number): Observable<void> {
return this.http.delete<void>(this.api(`/api/v1/admin/DeleteProject/${projectId}`));
}
upsertHobbies(hobbies: IHobby[]): Observable<IHobby[]> {
return this.http.post<IHobby[]>(this.api('/api/v1/admin/UpsertHobbies'), hobbies);
}
}

View File

@ -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();
}
}

View File

@ -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<AdminState> {
protected override store = inject(AdminStore);
constructor() {
super(inject(AdminStore));
}
// About
about$ = this.select('about');
aboutLoading$ = this.select(s => s.loading.about);
aboutError$ = this.select(s => s.error.about);
// Contact
contact$ = this.select('contact');
contactLoading$ = this.select(s => s.loading.contact);
contactError$ = this.select(s => s.error.contact);
// Projects
projects$ = this.select('projects');
projectsLoading$ = this.select(s => s.loading.projects);
projectsError$ = this.select(s => s.error.projects);
// Resume
resume$ = this.select('resume');
resumeLoading$ = this.select(s => s.loading.resume);
resumeError$ = this.select(s => s.error.resume);
getAbout() {
return this.getValue().about;
}
getContact() {
return this.getValue().contact;
}
getProjects() {
return this.getValue().projects;
}
getResume() {
return this.getValue().resume;
}
}

View File

@ -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<AdminSection, boolean>;
error: Record<AdminSection, string | null>;
}
export function createInitialState(): AdminState {
return {
about: null,
contact: null,
projects: null,
resume: null,
loading: { about: false, contact: false, projects: false, resume: false },
error: { about: null, contact: null, projects: null, resume: null }
};
}
@Injectable({ providedIn: 'root' })
@StoreConfig({ name: 'admin', resettable: true })
export class AdminStore extends Store<AdminState> {
constructor() {
super(createInitialState());
}
setSectionLoading(section: AdminSection, loading: boolean) {
this.update(state => ({
loading: { ...state.loading, [section]: loading }
}));
}
setSectionError(section: AdminSection, error: string | null) {
this.update(state => ({
error: { ...state.error, [section]: error }
}));
}
}

View File

@ -1,12 +0,0 @@
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(withRoutes(serverRoutes))
]
};
export const config = mergeApplicationConfig(appConfig, serverConfig);

View File

@ -1,17 +1,17 @@
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 { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { provideHttpClient, withFetch, withInterceptorsFromDi } from '@angular/common/http';
import { httpInterceptorProviders } from './interceptors';
import { provideHttpClient, withFetch, withInterceptors} from '@angular/common/http';
import { loadingInterceptor } from './interceptors/loading-interceptor';
import { AuthInterceptor } from './interceptors/auth-interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideHttpClient(withInterceptorsFromDi(), withFetch()),
httpInterceptorProviders,
provideRouter(routes), provideClientHydration(withEventReplay())
provideAnimations(),
provideHttpClient(withInterceptors([loadingInterceptor, AuthInterceptor]), withFetch()),
provideRouter(routes)
]
};

View File

@ -1,3 +1,6 @@
<main>
<router-outlet></router-outlet>
</main>
<div>
@if(loading()){
<app-spinner></app-spinner>
}
<router-outlet></router-outlet>
</div>

View File

@ -1,8 +0,0 @@
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: '**',
renderMode: RenderMode.Prerender
}
];

View File

@ -1,6 +1,53 @@
import { Routes } from '@angular/router';
import { OtpComponent } from './auth/otp/otp.component';
import { authGuard } from './guards/auth-guard';
import { AdminLayout } from './layout/admin-layout/admin-layout';
import { About } from './admin/about/about';
import { Resume } from './admin/resume/resume';
import { Projects } from './admin/projects/projects';
const enum AdminRouteTitles {
Login = 'Login',
About = 'About',
Resume = 'Resume',
Projects = 'Projects',
}
export const routes: Routes = [
{ path: '**', pathMatch: 'full', component: OtpComponent }
{
path: '',
redirectTo: 'about',
pathMatch: 'full'
},
{
path: '',
component: AdminLayout,
title: 'Admin',
children: [
{
path: 'login',
component: OtpComponent,
title: AdminRouteTitles.Login
},
{
path: 'about',
component: About,
canActivate: [authGuard],
title: AdminRouteTitles.About,
},
{
path: 'resume',
component: Resume,
canActivate: [authGuard],
title: AdminRouteTitles.Resume,
},
{
path: 'projects',
component: Projects,
canActivate: [authGuard],
title: AdminRouteTitles.Projects,
},
],
}
];

View File

@ -1,12 +1,17 @@
import { Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Component, CUSTOM_ELEMENTS_SCHEMA, inject, signal } from '@angular/core';
import { SpinnerComponent } from './spinner/spinner.component';
import { LoaderService } from './services/loader.service';
import { RouterModule } from "@angular/router";
@Component({
selector: 'app-root',
imports: [RouterOutlet],
imports: [SpinnerComponent, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './app.html',
styleUrl: './app.scss'
styleUrls: ['./app.scss']
})
export class App {
loader = inject(LoaderService);
protected readonly title = signal('portfolio-admin');
protected readonly loading = this.loader.isLoading;
}

View File

@ -1,23 +1,13 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BrowserModule } from '@angular/platform-browser';
import { OtpComponent } from './otp/otp.component';
import { ReactiveFormsModule } from '@angular/forms';
import { AuthInterceptor } from '../interceptors/auth-interceptor';
@NgModule({
declarations: [
OtpComponent
],
imports: [
CommonModule,
BrowserModule,
ReactiveFormsModule
],
providers:[
AuthInterceptor
]
})
export class AuthModule { }

View File

@ -1,35 +1,128 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
import { BehaviorSubject, map, Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { isPlatformBrowser } from '@angular/common';
import { Router } from '@angular/router';
interface ValidateOtpResponse {
accessToken: string;
}
interface RefreshTokenResponse {
accessToken: string;
}
@Injectable({
providedIn: 'root'
})
export class AuthService {
private readonly baseUrl = (environment.apiUrl ?? '').replace(/\/+$/, '');
private accessToken: string | null = null;
private platformId = inject(PLATFORM_ID);
public accessTokenSub = new BehaviorSubject<string | null>(null);
http = inject(HttpClient);
router = inject(Router);
constructor(private http: HttpClient) {}
private readonly storageKey = 'accessToken';
tokenReady$ = new BehaviorSubject<boolean | null>(null);
constructor() {
this.accessToken = this.safeGetToken();
}
private api(path: string) {
return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
}
sendOtp(email: string): Observable<any> {
// Call on app start, or from guard
async ensureTokenReady(): Promise<void>{
if(this.tokenReady$.value) return;
const stored = this.safeGetToken();
// try to restore from storage
if(stored){
this.accessTokenSub.next(stored);
this.tokenReady$.next(true);
return;
}
}
get currentToken(): string | null {
return this.safeGetToken();
}
safeSetToken(token: string) {
this.accessToken = token;
if (isPlatformBrowser(this.platformId)) {
localStorage.setItem(this.storageKey, token);
this.accessTokenSub.next(token);
}
}
private safeGetToken(): string | null {
try {
if (isPlatformBrowser(this.platformId)) {
const token = localStorage.getItem(this.storageKey);
this.accessTokenSub.next(token);
return token;
}
} catch (e) {
console.warn('Failed to read from localStorage:', e);
}
return null;
}
private safeRemoveToken() {
this.accessToken = null;
if (isPlatformBrowser(this.platformId)) {
localStorage.removeItem(this.storageKey);
this.accessTokenSub.next(null);
}
}
sendOtp(email: string): Observable<unknown> {
const formData = new FormData();
formData.append('email', email);
return this.http.post(this.api('/api/v1/auth/GenerateOtp'), formData);
}
verifyOtp(userId: string, otpCode: string): Observable<any> {
verifyOtp(userId: string, otpCode: string): Observable<void> {
const body = {
UserId: userId,
OtpCode: otpCode
};
return this.http.post(this.api('/api/v1/auth/ValidateOtp'), body);
UserId: userId,
OtpCode: otpCode
};
return this.http.post<ValidateOtpResponse>(this.api('/api/v1/auth/ValidateOtp'), body).pipe(map((response: ValidateOtpResponse) => {
if (response && response.accessToken) {
this.accessToken = response.accessToken;
this.safeSetToken(response.accessToken);
}
}));
}
getApiKey(): string{
getAccessToken(): string | null {
return this.accessToken;
}
logout(): void {
this.http.post<void>(this.api('/api/v1/auth/logout'), {}).subscribe();
this.accessToken = null;
this.safeRemoveToken();
this.router.navigateByUrl('login');
}
refreshToken(): Observable<RefreshTokenResponse> {
return this.http.post<RefreshTokenResponse>(this.api('/api/v1/auth/RefreshToken'), {});
}
getApiKey(): string {
return environment.apiKey;
}
isLoggedIn(): boolean {
return this.safeGetToken() != null;
}
}

View File

@ -1,40 +1,55 @@
<div class="otp-container">
<h2>🔐 Email Verification</h2>
<div class="verify-wrapper">
<div class="verify-card">
<!-- Step 1: Enter Email -->
@if (!isOtpSent()) {
<form [formGroup]="emailForm" (ngSubmit)="sendOtp()">
<label>Email Address</label>
<input type="email" formControlName="email" placeholder="Enter your email" />
@if(!isOtpSent()){
<h2 class="title">🔐 Login</h2>
}
@if(isOtpSent() && !isVerified()){
<h2 class="title">🔐 OTP Verification</h2>
}
<!-- Step 1: Enter Email -->
@if (!isOtpSent()) {
<form [formGroup]="emailForm" (ngSubmit)="sendOtp()" class="form-section">
<label for="email">Email Address</label>
<input id="email" type="email" formControlName="email" placeholder="Enter your email" />
<button type="submit" [disabled]="emailForm.invalid">Send OTP</button>
</form>
}
<!-- Step 2: Enter OTP -->
@if (isOtpSent() && !isVerified()) {
<div>
<p>OTP sent to <b>{{ emailForm.value.email }}</b></p>
}
<!-- Step 2: OTP Input -->
@if (isOtpSent() && !isVerified()) {
<div class="otp-section">
<form [formGroup]="otpForm" (ngSubmit)="verifyOtp()">
<label>Enter 6-digit OTP</label>
<input type="text" maxlength="6" formControlName="otp" placeholder="123456" />
<button type="submit" [disabled]="otpForm.invalid">Verify OTP</button>
<label for="otp">Enter 6-digit OTP</label>
<input id="otp" type="text" maxlength="6" formControlName="otp" placeholder="123456" />
<button type="submit" [disabled]="otpForm.invalid">
Verify OTP
</button>
</form>
<button (click)="resendOtp()" [disabled]="countdown() > 0">
Resend OTP @if (countdown() > 0) {
<span>({{ countdown() }}s)</span>
<button class="resend-btn" (click)="resendOtp()" [disabled]="countdown() > 0">
Resend OTP
@if (countdown() > 0) {
<span>({{ countdown() }}s)</span>
}
</button>
</div>
}
}
<!-- Step 3: Success -->
@if (isVerified()) {
<div>
<p class="success">✅ Your email has been verified successfully!</p>
@if (isError()) {
<div class="error-banner">
<i class="fa-solid fa-circle-exclamation"></i>
<span>{{ message() }}</span>
</div>
}
}
<p class="message">{{ message() }}</p>
@if (!isError() && isOtpSent() && !isVerified()) {
<div class="success-banner">
<i class="fa-solid fa-circle-check"></i>
<span>{{ message() }}</span>
</div>
}
</div>
</div>

View File

@ -1,47 +1,193 @@
.otp-container {
max-width: 400px;
margin: 60px auto;
padding: 30px;
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
/* Background wrapper */
.verify-wrapper {
width: 100%;
height: 100vh;
background: #0e0e0e;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
/* Card */
.verify-card {
width: 420px;
/* NEW: More contrast from background */
background: var(--bg-gradient-onyx);
border-radius: 18px;
padding: 35px 40px;
/* NEW: Clean gold glow */
box-shadow:
0 0 12px rgba(227, 179, 65, 0.25),
0 10px 35px rgba(0, 0, 0, 0.65);
/* NEW: Gold border highlight */
border: 1px solid rgba(227, 179, 65, 0.15);
/* Slight glass effect */
backdrop-filter: blur(6px);
}
/* Title */
.title {
text-align: center;
color: #fff;
font-size: 24px;
font-weight: 600;
margin-bottom: 30px;
}
/* Labels */
label {
color: #d6d6d6;
font-size: 14px;
margin-bottom: 6px;
display: block;
}
/* Inputs */
input {
width: 100%;
padding: 14px;
background: #111;
border: 1px solid #333;
color: #fff;
border-radius: 10px;
font-size: 15px;
margin-bottom: 18px;
outline: none;
transition: 0.25s;
&:focus {
border-color: #e3b341;
box-shadow: 0 0 5px rgba(227, 179, 65, 0.45);
}
&::placeholder {
color: #777;
}
}
/* Primary buttons */
button {
width: 100%;
padding: 14px;
background: #e3b341;
border: none;
color: #111;
font-size: 16px;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
transition: 0.25s;
text-align: center;
h2 {
margin-bottom: 20px;
&:disabled {
background: #555;
color: #aaa;
cursor: not-allowed;
}
input {
width: 100%;
padding: 10px;
margin: 10px 0;
font-size: 16px;
&:not(:disabled):hover {
background: #f2c85c;
}
}
/* Resend OTP button */
.resend-btn {
margin-top: 12px;
background: transparent !important;
color: #e3b341 !important;
border: 1px solid #e3b341;
font-size: 14px;
&:hover:not(:disabled) {
background: rgba(227, 179, 65, 0.1);
}
button {
width: 100%;
margin-top: 10px;
padding: 10px;
background-color: #007bff;
border: none;
color: white;
font-size: 16px;
border-radius: 8px;
cursor: pointer;
&:disabled {
background: #aaa;
cursor: not-allowed;
}
&:disabled {
border-color: #555;
color: #777;
}
}
.success {
color: green;
font-weight: 600;
/* Success message */
.success-msg {
margin-top: 10px;
font-size: 16px;
color: #78ff8c;
text-align: center;
font-weight: 500;
}
/* Error / general message */
.error-message {
margin-top: 12px;
color: #ff6b6b;
text-align: center;
font-size: 14px;
}
.error-text {
color: #ff6b6b;
font-size: 13px;
margin: 6px 0 0;
text-align: left;
}
.error-banner {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 80, 80, 0.1);
border: 1px solid rgba(255, 80, 80, 0.3);
border-radius: 8px;
padding: 10px 14px;
margin: 10px 0 10px;
color: #ff6b6b;
font-size: 13px;
animation: shakeError 0.4s ease;
i {
font-size: 15px;
flex-shrink: 0;
}
}
.message {
margin-top: 15px;
color: #555;
@keyframes shakeError {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(6px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(4px); }
}
.info-text {
color: #ccc;
font-size: 14px;
margin-bottom: 10px;
}
/* Success banner for OTP sent */
.success-banner {
display: flex;
align-items: center;
gap: 8px;
background: rgba(120, 255, 140, 0.07);
border: 1px solid rgba(120, 255, 140, 0.25);
border-radius: 8px;
padding: 10px 14px;
margin: 10px 0 10px;
color: #78ff8c;
font-size: 13px;
i {
font-size: 15px;
flex-shrink: 0;
color: #78ff8c;
}
}

View File

@ -1,23 +1,31 @@
import { Component, signal } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Component, inject, OnInit, signal } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { AuthService } from '../auth.service';
import { ActivatedRoute, Router } from '@angular/router';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-otp',
standalone: false,
templateUrl: './otp.component.html',
styleUrls: ['./otp.component.scss']
styleUrls: ['./otp.component.scss'],
imports: [ReactiveFormsModule, CommonModule]
})
export class OtpComponent {
export class OtpComponent implements OnInit {
emailForm: FormGroup;
otpForm: FormGroup;
isOtpSent = signal(false);
isVerified = signal(false);
isError = signal(false);
message = signal('');
countdown = signal(0);
timer: any;
timer: NodeJS.Timeout | undefined;
returnUrl = '';
fb: FormBuilder = inject(FormBuilder);
authService: AuthService = inject(AuthService);
router: Router = inject(Router);
route: ActivatedRoute = inject(ActivatedRoute);
constructor(private fb: FormBuilder, private authService: AuthService) {
constructor() {
this.emailForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
});
@ -25,21 +33,42 @@ export class OtpComponent {
this.otpForm = this.fb.group({
otp: ['', [Validators.required, Validators.pattern(/^[0-9]{6}$/)]],
});
if(this.authService.isLoggedIn()){
this.router.navigate(['']);
}
}
ngOnInit() {
this.returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') || '';
}
sendOtp() {
if (this.emailForm.invalid) return;
const email = this.emailForm.value.email;
this.authService.sendOtp(email).subscribe(() => {
this.isOtpSent.set(true);
this.message.set('OTP sent successfully!');
this.startTimer(30); // 30 seconds countdown
this.authService.sendOtp(email).subscribe({
next: () => {
this.isOtpSent.set(true);
this.isError.set(false);
this.message.set(`OTP sent to ${email}.`);
this.startTimer(30); // 30 seconds countdown
},
error: (err) => {
this.isError.set(true);
if (err.status === 404) {
this.message.set('Email not found. Please check and try again.');
} else {
this.message.set('Failed to send OTP. Please try again.');
}
}
});
}
}
resendOtp() {
if (this.countdown() > 0) return;
this.otpForm.reset();
this.sendOtp();
}
@ -58,13 +87,14 @@ export class OtpComponent {
const { otp: otpCode } = this.otpForm.value;
this.authService.verifyOtp(userId, otpCode).subscribe({
next: (res) => {
next: () => {
this.isVerified.set(true);
this.message.set(res.message || 'OTP verified successfully ✅');
this.router.navigateByUrl(this.returnUrl); // Navigate to dashboard or desired route after successful verification
},
error: (err) => {
this.isError.set(true);
if (err.status === 401 && err.error?.message) {
this.message.set(err.error.message); // "OTP Expired" or "Invalid OTP"
this.message.set(err.error.message);
} else {
this.message.set('Something went wrong. Please try again.');
}

View File

@ -1,16 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { CanActivateFn } from '@angular/router';
import { AuthGuard } from './auth-guard';
import { authGuard } from './auth-guard';
describe('AuthGuard', () => {
let guard: AuthGuard;
describe('authGuard', () => {
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => authGuard(...guardParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
guard = TestBed.inject(AuthGuard);
});
it('should be created', () => {
expect(guard).toBeTruthy();
expect(executeGuard).toBeTruthy();
});
});

View File

@ -1,14 +1,20 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, GuardResult, MaybeAsync, RouterStateSnapshot } from '@angular/router';
import { inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../auth/auth.service';
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate {
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): MaybeAsync<GuardResult> {
export const authGuard: CanActivateFn = (route, state) => {
const platformId = inject(PLATFORM_ID);
// During SSR there is no localStorage — skip the guard and let the client enforce auth after hydration
if (!isPlatformBrowser(platformId)) {
return true;
}
}
const auth = inject(AuthService);
const router = inject(Router);
return auth.currentToken
? true
: router.parseUrl(`login?returnUrl=${state.url}`);
};

View File

@ -1,32 +1,124 @@
import { Injectable } from '@angular/core';
import { inject } from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpHandlerFn,
HttpEvent,
HttpInterceptor
HttpErrorResponse,
HttpInterceptorFn
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { BehaviorSubject, catchError, filter, Observable, switchMap, take, throwError } from 'rxjs';
import { AuthService } from '../auth/auth.service';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
let refreshInProgress = false;
const refreshSubject = new BehaviorSubject<string | null>(null);
constructor(public authSvc: AuthService) {}
export const AuthInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
const apiKey = this.authSvc.getApiKey();
let headers = request.headers;
if (apiKey) {
headers = headers.set('XApiKey', apiKey);
}
// Ensure Content-Type is JSON for POST/PUT if missing
if (!(request.body instanceof FormData) && !headers.has('Content-Type') && (request.method === 'POST' || request.method === 'PUT')) {
headers = headers.set('Content-Type', 'application/json');
}
const authReq = request.clone({ headers });
return next.handle(authReq);
const authSvc: AuthService = inject(AuthService);
let headers = req.headers;
// add API key
const apiKey = authSvc.getApiKey();
if (apiKey) {
headers = headers.set('XApiKey', apiKey);
}
}
// add content type if needed
if (!(req.body instanceof FormData) &&
(req.method === 'POST' || req.method === 'PUT') &&
!headers.has('Content-Type')) {
headers = headers.set('Content-Type', 'application/json');
}
// add access token
const token = authSvc.currentToken;
if (token) {
headers = headers.set('Authorization', `Bearer ${token}`);
}
// final cloned request
const clonedRequest = req.clone({
headers,
withCredentials: true
});
return next(clonedRequest).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status !== 401) {
return throwError(() => err);
}
// Don't attempt token refresh for auth endpoints
const isAuthUrl = req.url.includes('/auth/');
if (isAuthUrl) {
return throwError(() => err);
}
// if refresh is already in progress
if (refreshInProgress) {
return refreshSubject.pipe(
filter(t => t !== null),
take(1),
switchMap(newToken => {
const retryReq = clonedRequest.clone({
headers: clonedRequest.headers.set('Authorization', `Bearer ${newToken}`),
withCredentials: true
});
return next(retryReq);
})
);
}
// start refresh
refreshInProgress = true;
refreshSubject.next(null);
const refresh$ = authSvc.refreshToken();
// refreshToken() must return an Observable. Otherwise handle gracefully
if (!refresh$) {
refreshInProgress = false;
authSvc.logout();
return throwError(() => err);
}
return refresh$.pipe(
switchMap(res => {
refreshInProgress = false;
const newToken = res?.accessToken;
if (!newToken) {
authSvc.logout();
return throwError(() => err);
}
authSvc.safeSetToken(newToken);
refreshSubject.next(newToken);
const retryReq = clonedRequest.clone({
headers: clonedRequest.headers.set('Authorization', `Bearer ${newToken}`),
withCredentials: true
});
return next(retryReq);
}),
catchError(refreshErr => {
refreshInProgress = false;
refreshSubject.next(null);
authSvc.logout();
return throwError(() => refreshErr);
})
);
})
);
};

View File

@ -1,7 +0,0 @@
import { HTTP_INTERCEPTORS } from "@angular/common/http";
import { AuthInterceptor } from "./auth-interceptor";
export const httpInterceptorProviders = [
{provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true},
]

View File

@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { HttpInterceptorFn } from '@angular/common/http';
import { loadingInterceptor } from './loading-interceptor';
describe('loadingInterceptor', () => {
const interceptor: HttpInterceptorFn = (req, next) =>
TestBed.runInInjectionContext(() => loadingInterceptor(req, next));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(interceptor).toBeTruthy();
});
});

View File

@ -0,0 +1,29 @@
import { HttpEvent, HttpInterceptorFn, HttpRequest, HttpHandlerFn } from "@angular/common/http";
import { inject } from "@angular/core";
import { Observable, finalize } from "rxjs";
import { LoaderService } from "../services/loader.service";
export const loadingInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
const loader = inject(LoaderService);
const isRefresh = req.url.includes('/auth/RefreshToken');
if (!isRefresh) {
loader.increase();
}
return next(req).pipe(
// catchError(err => {
// if (!isRefresh) loader.decrease();
// throw err;
// }),
finalize(() => {
if (!isRefresh) loader.decrease();
})
);
};

View File

@ -0,0 +1,32 @@
@if(!loggedIn()){
<router-outlet></router-outlet>
} @else {
<main>
<app-contact></app-contact>
<div class="main-content">
<nav class="navbar">
<ul class="navbar-list">
<li class="navbar-item">
<button routerLink="about" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }"
class="navbar-link">About</button>
</li>
<li class="navbar-item">
<button routerLink="resume" routerLinkActive="active" class="navbar-link">Resume</button>
</li>
<li class="navbar-item">
<button routerLink="projects" routerLinkActive="active" class="navbar-link">Projects</button>
</li>
</ul>
</nav>
<div class="page-container">
<router-outlet></router-outlet>
</div>
</div>
</main>
}

View File

@ -0,0 +1,38 @@
import { Routes } from '@angular/router';
import { About } from '../../admin/about/about';
import { Resume } from '../../admin/resume/resume';
import { Projects } from '../../admin/projects/projects';
/**
* Admin layout child routes.
*
* Notes:
* - Uses a dedicated `Routes` constant for better maintainability.
* - Titles are centralized as constants to avoid magic strings.
* - `pathMatch: 'full'` on the default route ensures exact matching.
* - Ready for lazy-loading or guards if needed in future.
*/
const enum AdminRouteTitles {
About = 'About',
Resume = 'Resume',
Projects = 'Projects',
}
export const adminLayoutRoutes: Routes = [
{
path: '',
component: About,
title: AdminRouteTitles.About,
},
{
path: 'resume',
component: Resume,
title: AdminRouteTitles.Resume,
},
{
path: 'projects',
component: Projects,
title: AdminRouteTitles.Projects,
},
];

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AdminLayout } from './admin-layout';
describe('AdminLayout', () => {
let component: AdminLayout;
let fixture: ComponentFixture<AdminLayout>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AdminLayout]
})
.compileComponents();
fixture = TestBed.createComponent(AdminLayout);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,21 @@
import { Component, inject, signal } from '@angular/core';
import { RouterModule } from '@angular/router';
import { Contact } from "../../admin/contact/contact";
import { AuthService } from '../../auth/auth.service';
@Component({
selector: 'app-admin-layout',
imports: [RouterModule, Contact],
templateUrl: './admin-layout.html',
styleUrl: './admin-layout.scss'
})
export class AdminLayout {
authSvc = inject(AuthService);
loggedIn = signal(false);
constructor() {
this.loggedIn.set(this.authSvc.currentToken !== null);
this.authSvc.accessTokenSub.subscribe(token => {
this.loggedIn.set(token !== null);
});
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { LoaderService } from './loader.service';
describe('LoaderService', () => {
let service: LoaderService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(LoaderService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,24 @@
import { Injectable, signal, computed } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class LoaderService {
private _requestCount = signal(0);
// computed loader state
isLoading = computed(() => this._requestCount() > 0);
increase() {
this._requestCount.update(c => c + 1);
}
decrease() {
this._requestCount.update(c => Math.max(0, c - 1));
}
reset() {
this._requestCount.set(0);
}
}

View File

@ -0,0 +1,15 @@
<div class="confirm-dialog" role="alertdialog">
<div class="confirm-header">
<i class="fa-solid fa-triangle-exclamation warn-icon"></i>
<h3>{{ title }}</h3>
</div>
<p class="confirm-message">{{ message }}</p>
<div class="confirm-actions">
<button class="btn btn-cancel" (click)="onCancel()">{{ cancelLabel }}</button>
<button class="btn" [class.btn-warn]="isWarn" [class.btn-primary]="!isWarn" (click)="onConfirm()">
{{ confirmLabel }}
</button>
</div>
</div>

View File

@ -0,0 +1,86 @@
.confirm-dialog {
padding: 24px;
background: var(--eerie-black-2, #1e1e1e);
border: 1px solid var(--jet, #383838);
border-radius: 14px;
color: var(--white-1, #fff);
min-width: 320px;
max-width: 420px;
}
.confirm-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
h3 {
margin: 0;
font-size: 1.15rem;
color: var(--white-1, #fff);
}
}
.warn-icon {
color: #ff6b6b;
font-size: 1.2rem;
}
.confirm-message {
margin: 0 0 20px;
font-size: 0.92rem;
line-height: 1.5;
color: var(--white-2, #ddd);
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.btn {
padding: 8px 18px;
border: none;
border-radius: 8px;
font-size: 0.88rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease, transform 0.1s ease;
&:active {
transform: scale(0.97);
}
}
.btn-cancel {
background: rgba(255, 255, 255, 0.06);
color: var(--light-gray-70, #aaa);
border: 1px solid rgba(255, 255, 255, 0.1);
&:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--white-1, #fff);
}
}
.btn-warn {
background: rgba(255, 70, 70, 0.15);
color: #ff6b6b;
border: 1px solid rgba(255, 70, 70, 0.25);
&:hover {
background: rgba(255, 70, 70, 0.25);
color: #ff4444;
}
}
.btn-primary {
background: rgba(227, 179, 65, 0.15);
color: var(--orange-yellow-crayola, #e3b341);
border: 1px solid rgba(227, 179, 65, 0.25);
&:hover {
background: rgba(227, 179, 65, 0.25);
}
}

View File

@ -0,0 +1,50 @@
import { Component, inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
export interface ConfirmDialogData {
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
confirmColor?: 'warn' | 'primary' | 'accent';
}
@Component({
selector: 'app-confirm-dialog',
standalone: true,
imports: [MatDialogModule],
templateUrl: './confirm-dialog.html',
styleUrls: ['./confirm-dialog.scss']
})
export class ConfirmDialogComponent {
private dialogRef = inject(MatDialogRef<ConfirmDialogComponent>);
data: ConfirmDialogData = inject(MAT_DIALOG_DATA);
get title(): string {
return this.data.title ?? 'Confirm';
}
get message(): string {
return this.data.message;
}
get confirmLabel(): string {
return this.data.confirmLabel ?? 'Delete';
}
get cancelLabel(): string {
return this.data.cancelLabel ?? 'Cancel';
}
get isWarn(): boolean {
return (this.data.confirmColor ?? 'warn') === 'warn';
}
onCancel(): void {
this.dialogRef.close(false);
}
onConfirm(): void {
this.dialogRef.close(true);
}
}

View File

@ -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' };
}

View File

@ -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[];
}

View File

@ -0,0 +1,141 @@
<form [formGroup]="form">
@for (f of config.fields; track f) {
<ng-container *ngIf="f.type !== 'array' && f.type !== 'hidden'">
@if (f.type === 'text') {
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ f.label }}</mat-label>
<input matInput [id]="f.name" [formControlName]="f.name" />
<mat-hint *ngIf="f.placeholder">{{ f.placeholder }}</mat-hint>
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
This field is required.
</mat-error>
</mat-form-field>
}
@if (f.type === 'textarea') {
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ f.label }}</mat-label>
<textarea matInput [id]="f.name" cdkTextareaAutosize cdkAutosizeMinRows="3" cdkAutosizeMaxRows="6" [formControlName]="f.name"></textarea>
<mat-hint *ngIf="f.placeholder">{{ f.placeholder }}</mat-hint>
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
This field is required.
</mat-error>
</mat-form-field>
}
@if (f.type === 'number') {
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ f.label }}</mat-label>
<input matInput [id]="f.name" type="number" [formControlName]="f.name" />
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
Please enter a valid number.
</mat-error>
</mat-form-field>
}
@if (f.type === 'select') {
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ f.label }}</mat-label>
<mat-select [id]="f.name" [formControlName]="f.name">
@for (opt of f.options; track opt) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
Please select a value.
</mat-error>
</mat-form-field>
}
@if (f.type === 'year') {
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ f.label }}</mat-label>
<mat-select [id]="f.name" [formControlName]="f.name" [compareWith]="compareYearValues">
@if (f.yearRange?.allowPresent) {
<mat-option value="Present">Present</mat-option>
}
@for (yr of getYearOptions(f); track yr) {
<mat-option [value]="yr">{{ yr }}</mat-option>
}
</mat-select>
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
Please select a year.
</mat-error>
</mat-form-field>
}
@if (f.type === 'date') {
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ f.label }}</mat-label>
<input matInput [matDatepicker]="picker" [id]="f.name" [formControlName]="f.name" [placeholder]="f.placeholder || ''" />
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
Please select a date.
</mat-error>
</mat-form-field>
}
@if (f.type === 'file') {
<div>
<label [for]="f.name">{{ f.label }}</label>
<input [id]="f.name" type="file" (change)="onFileChange($event, f.name)" />
</div>
}
</ng-container>
<ng-container *ngIf="f.type === 'array'">
<div>
<h3>{{ f.label }}</h3>
<div [formArrayName]="f.name">
@for (item of getArrayControls(f.name); track item; let i = $index) {
<div [formGroupName]="i" class="array-item">
<div class="array-item-fields">
@for (sub of f.itemConfig; track sub) {
@if (sub.type !== 'hidden') {
@if (sub.type === 'date') {
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ sub.label }}</mat-label>
<input matInput [matDatepicker]="subPicker" [id]="sub.name + '_' + i" [formControlName]="sub.name" [placeholder]="sub.placeholder || ''" />
<mat-datepicker-toggle matIconSuffix [for]="subPicker"></mat-datepicker-toggle>
<mat-datepicker #subPicker></mat-datepicker>
</mat-form-field>
} @else if (sub.type === 'year') {
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ sub.label }}</mat-label>
<mat-select [id]="sub.name + '_' + i" [formControlName]="sub.name" [compareWith]="compareYearValues">
@if (sub.yearRange?.allowPresent) {
<mat-option value="Present">Present</mat-option>
}
@for (yr of getYearOptions(sub); track yr) {
<mat-option [value]="yr">{{ yr }}</mat-option>
}
</mat-select>
</mat-form-field>
} @else {
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ sub.label }}</mat-label>
<input matInput [id]="sub.name + '_' + i" [formControlName]="sub.name" [placeholder]="sub.placeholder || ''" />
</mat-form-field>
}
}
}
</div>
<div class="array-item-actions">
<button type="button" class="remove-link" (click)="removeArrayItem(f, i)">
<i class="fa-solid fa-trash-can"></i> Remove
</button>
</div>
</div>
}
</div>
<button type="button" class="add-btn" (click)="addArrayItem(f)">
<i class="fa-solid fa-plus"></i> Add Item
</button>
</div>
</ng-container>
}
</form>

Some files were not shown because too many files have changed in this diff Show More