Compare commits

..

No commits in common. "9065626d40bfea9db11fe1f5d21413d2234186e6" and "94f4305615956df212d744f241f04c614e6f9f7a" have entirely different histories.

141 changed files with 236 additions and 8766 deletions

View File

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

View File

@ -1,43 +0,0 @@
// @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,8 +7,7 @@
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"serve:ssr:portfolio-admin": "node dist/portfolio-admin/server/server.mjs",
"lint": "ng lint"
"serve:ssr:portfolio-admin": "node dist/portfolio-admin/server/server.mjs"
},
"prettier": {
"printWidth": 100,
@ -24,7 +23,6 @@
},
"private": true,
"dependencies": {
"@angular/animations": "20.3.4",
"@angular/cdk": "^20.2.5",
"@angular/common": "^20.3.0",
"@angular/compiler": "^20.3.0",
@ -35,7 +33,6 @@
"@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"
@ -47,15 +44,12 @@
"@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-eslint": "8.46.3"
"typescript": "~5.9.2"
}
}
}

View File

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

View File

@ -1,159 +0,0 @@
'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");
}
}
});
}

View File

@ -1,17 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 805 B

View File

@ -1,41 +0,0 @@
@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

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

View File

@ -1,37 +0,0 @@
.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

@ -1,23 +0,0 @@
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

@ -1,89 +0,0 @@
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

@ -1,132 +0,0 @@
@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

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

View File

@ -1,86 +0,0 @@
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

@ -1,23 +0,0 @@
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

@ -1,121 +0,0 @@
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

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

View File

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

View File

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

View File

@ -1,21 +0,0 @@
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

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

View File

@ -1,15 +0,0 @@
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

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

View File

@ -1,17 +0,0 @@
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

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

View File

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

View File

@ -1,101 +0,0 @@
<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

@ -1,107 +0,0 @@
.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

@ -1,20 +0,0 @@
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

@ -1,89 +0,0 @@
<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

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

View File

@ -1,97 +0,0 @@
.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

@ -1,23 +0,0 @@
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

@ -1,223 +0,0 @@
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

@ -1,153 +0,0 @@
@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

@ -1,11 +0,0 @@
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

@ -1,41 +0,0 @@
.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

@ -1,23 +0,0 @@
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

@ -1,216 +0,0 @@
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

@ -1,16 +0,0 @@
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

@ -1,50 +0,0 @@
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

@ -1,140 +0,0 @@
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

@ -1,48 +0,0 @@
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

@ -1,48 +0,0 @@
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

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

View File

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

View File

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

View File

@ -1,53 +1,6 @@
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: '',
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,
},
],
}
{ path: '**', pathMatch: 'full', component: OtpComponent }
];

View File

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

View File

@ -1,13 +1,23 @@
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,128 +1,35 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
import { BehaviorSubject, map, Observable } from 'rxjs';
import { Injectable } from '@angular/core';
import { Observable, of } 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);
private readonly storageKey = 'accessToken';
tokenReady$ = new BehaviorSubject<boolean | null>(null);
constructor() {
this.accessToken = this.safeGetToken();
}
constructor(private http: HttpClient) {}
private api(path: string) {
return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
}
// 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> {
sendOtp(email: string): Observable<any> {
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<void> {
verifyOtp(userId: string, otpCode: string): Observable<any> {
const 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);
}
}));
UserId: userId,
OtpCode: otpCode
};
return this.http.post(this.api('/api/v1/auth/ValidateOtp'), body);
}
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 {
getApiKey(): string{
return environment.apiKey;
}
isLoggedIn(): boolean {
return this.safeGetToken() != null;
}
}

View File

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

View File

@ -1,193 +1,47 @@
/* 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;
.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);
text-align: center;
&:disabled {
background: #555;
color: #aaa;
cursor: not-allowed;
h2 {
margin-bottom: 20px;
}
&: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);
input {
width: 100%;
padding: 10px;
margin: 10px 0;
font-size: 16px;
}
&:disabled {
border-color: #555;
color: #777;
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;
}
}
}
/* 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;
.success {
color: green;
font-weight: 600;
}
}
@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;
.message {
margin-top: 15px;
color: #555;
}
}

View File

@ -1,31 +1,23 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Component, signal } from '@angular/core';
import { FormBuilder, FormGroup, 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'],
imports: [ReactiveFormsModule, CommonModule]
styleUrls: ['./otp.component.scss']
})
export class OtpComponent implements OnInit {
export class OtpComponent {
emailForm: FormGroup;
otpForm: FormGroup;
isOtpSent = signal(false);
isVerified = signal(false);
isError = signal(false);
message = signal('');
countdown = signal(0);
timer: NodeJS.Timeout | undefined;
returnUrl = '';
fb: FormBuilder = inject(FormBuilder);
authService: AuthService = inject(AuthService);
router: Router = inject(Router);
route: ActivatedRoute = inject(ActivatedRoute);
timer: any;
constructor() {
constructor(private fb: FormBuilder, private authService: AuthService) {
this.emailForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
});
@ -33,42 +25,21 @@ export class OtpComponent implements OnInit {
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({
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.');
}
}
this.authService.sendOtp(email).subscribe(() => {
this.isOtpSent.set(true);
this.message.set('OTP sent successfully!');
this.startTimer(30); // 30 seconds countdown
});
}
}
resendOtp() {
if (this.countdown() > 0) return;
this.otpForm.reset();
this.sendOtp();
}
@ -87,14 +58,13 @@ export class OtpComponent implements OnInit {
const { otp: otpCode } = this.otpForm.value;
this.authService.verifyOtp(userId, otpCode).subscribe({
next: () => {
next: (res) => {
this.isVerified.set(true);
this.router.navigateByUrl(this.returnUrl); // Navigate to dashboard or desired route after successful verification
this.message.set(res.message || 'OTP verified successfully ✅');
},
error: (err) => {
this.isError.set(true);
if (err.status === 401 && err.error?.message) {
this.message.set(err.error.message);
this.message.set(err.error.message); // "OTP Expired" or "Invalid OTP"
} else {
this.message.set('Something went wrong. Please try again.');
}

View File

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

View File

@ -1,20 +1,14 @@
import { inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../auth/auth.service';
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, GuardResult, MaybeAsync, RouterStateSnapshot } from '@angular/router';
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)) {
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate {
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): MaybeAsync<GuardResult> {
return true;
}
const auth = inject(AuthService);
const router = inject(Router);
return auth.currentToken
? true
: router.parseUrl(`login?returnUrl=${state.url}`);
};
}

View File

@ -1,124 +1,32 @@
import { inject } from '@angular/core';
import { Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandlerFn,
HttpHandler,
HttpEvent,
HttpErrorResponse,
HttpInterceptorFn
HttpInterceptor
} from '@angular/common/http';
import { BehaviorSubject, catchError, filter, Observable, switchMap, take, throwError } from 'rxjs';
import { Observable } from 'rxjs';
import { AuthService } from '../auth/auth.service';
let refreshInProgress = false;
const refreshSubject = new BehaviorSubject<string | null>(null);
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
export const AuthInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
constructor(public authSvc: AuthService) {}
const authSvc: AuthService = inject(AuthService);
let headers = req.headers;
// add API key
const apiKey = authSvc.getApiKey();
if (apiKey) {
headers = headers.set('XApiKey', apiKey);
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);
}
// 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

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

View File

@ -1,17 +0,0 @@
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

@ -1,29 +0,0 @@
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

@ -1,32 +0,0 @@
@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

@ -1,38 +0,0 @@
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

@ -1,23 +0,0 @@
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

@ -1,21 +0,0 @@
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

@ -1,16 +0,0 @@
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

@ -1,24 +0,0 @@
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

@ -1,15 +0,0 @@
<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

@ -1,86 +0,0 @@
.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

@ -1,50 +0,0 @@
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

@ -1,10 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,141 +0,0 @@
<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