Refactor code structure for improved readability and maintainability

This commit is contained in:
2025-11-15 12:51:32 +05:30
parent 94f4305615
commit d0025d55ef
117 changed files with 5992 additions and 157 deletions
+34
View File
@@ -0,0 +1,34 @@
<article class="about active" data-page="about">
<header>
<h2 class="h2 article-title">About me</h2>
</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">
<li class="service-item" *ngFor="let hobby of model.hobbies">
<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>
+6
View File
@@ -0,0 +1,6 @@
import { IHobby } from "../models/hobby.model";
export interface IAbout{
about: string;
hobbies: IHobby[];
}
+4
View File
@@ -0,0 +1,4 @@
.text-style {
white-space: pre-line;
font-family: var(--ff-poppins);
}
+23
View File
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { About } from './about';
describe('About', () => {
let component: About;
let fixture: ComponentFixture<About>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [About]
})
.compileComponents();
fixture = TestBed.createComponent(About);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
+29
View File
@@ -0,0 +1,29 @@
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminService } from '../services/admin.service';
import { IAbout } from './about.model';
import { BaseComponent } from '../base.component';
@Component({
selector: 'app-about',
imports: [CommonModule],
templateUrl: './about.html',
styleUrl: './about.scss'
})
export class About extends BaseComponent<IAbout> implements OnInit {
constructor() {
const svc = inject(AdminService);
super(svc);
}
ngOnInit(): void {
this.getAbout();
}
getAbout(): void{
this.svc.getHobbies(this.candidateId).subscribe((response: IAbout) => {
this.svc.about = this.svc.about ?? response;
this.assignData(response);
});
}
}
+19
View File
@@ -0,0 +1,19 @@
import { signal } from "@angular/core";
import { environment } from "../../environments/environment";
import { ICv } from "./models/cv.model";
import { AdminService } from "./services/admin.service";
export abstract class BaseComponent<T extends object> {
public model: T = {} as T;
candidateId = 1;
imagesOrigin: string = environment.apiUrl + '/images/';
isDataLoading = signal(false);
constructor(public svc: AdminService) {
}
assignData(response: Partial<ICv> | unknown){
Object.assign(this.model, response);
}
}
+118
View File
@@ -0,0 +1,118 @@
<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">
<h1 class="name" title="{{model.candidate?.displayName}}">{{model.candidate?.displayName}}</h1>
<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>
</aside>
+8
View File
@@ -0,0 +1,8 @@
import { ICandidate } from "../models/candidate.model";
import { ISocialLinks } from "../models/social-links.model";
export interface IContactModel {
title: string;
candidate?: ICandidate;
socialLinks?: ISocialLinks;
}
+3
View File
@@ -0,0 +1,3 @@
img {
border-radius: inherit;
}
+23
View File
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Contact } from './contact';
describe('Contact', () => {
let component: Contact;
let fixture: ComponentFixture<Contact>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Contact]
})
.compileComponents();
fixture = TestBed.createComponent(Contact);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
+31
View File
@@ -0,0 +1,31 @@
import { Component, inject, OnInit } from '@angular/core';
import { IContactModel } from './contact.model';
import { AdminService } from '../services/admin.service';
import { BaseComponent } from '../base.component';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-contact',
imports: [CommonModule],
templateUrl: './contact.html',
styleUrl: './contact.scss'
})
export class Contact extends BaseComponent<IContactModel> implements OnInit {
sideBarExpanded = false;
displayName!: string;
constructor(){
const svc = inject(AdminService);
super(svc);
}
ngOnInit(): void {
this.getCandidateAndSocialLinks();
}
getCandidateAndSocialLinks(){
this.svc.getCandidateWithSocialLinks(this.candidateId).subscribe((response: IContactModel) => {
this.svc.candidateAndSocialLinks = this.svc.candidateAndSocialLinks ?? response;
this.assignData(response);
});
}
}
+9
View File
@@ -0,0 +1,9 @@
export interface IAcademic{
academicId: number;
institution: string;
startYear: number;
endYear: number;
degree: string;
period: string;
degreeSpecialization: string;
}
+10
View File
@@ -0,0 +1,10 @@
export interface ICandidate{
firstName: string;
lastName: string;
dob: string;
email: string;
phone: string;
address: string;
avatar: string;
displayName: string;
}
+21
View File
@@ -0,0 +1,21 @@
import { IAcademic } from "./academic.model";
import { ICandidate } from "./candidate.model";
import { IExperience } from "./experience.model";
import { IHobby } from "./hobby.model";
import { IProject } from "./project.model";
import { ISkill } from "./skill.model";
import { ISocialLinks } from "./social-links.model";
export interface ICv{
resumeId: number;
title: string;
about: string;
candidate: ICandidate;
socialLinks: ISocialLinks;
academics: IAcademic[];
skills: ISkill[];
experiences: IExperience[];
hobbies: IHobby[];
projects: IProject[];
projectsCategories: string[];
}
@@ -0,0 +1,5 @@
export interface IExperienceDetails{
id: number;
details: string;
order: number;
}
+12
View File
@@ -0,0 +1,12 @@
import { IExperienceDetails } from "./experience-details.model";
export interface IExperience{
experienceId: number;
title: string;
description: string;
company: string;
startYear: string;
endYear: string;
period: string;
details: IExperienceDetails[];
}
+6
View File
@@ -0,0 +1,6 @@
export interface IHobby{
hobbyId: number;
name: string;
description: string;
icon: string;
}
+11
View File
@@ -0,0 +1,11 @@
export interface IProject{
projectId: number;
name: string;
description: string;
categories: string[];
categoryList: string[];
roles: string[];
responsibilities: string[];
technologiesUsed: string[];
imagePath: string;
}
+6
View File
@@ -0,0 +1,6 @@
export interface ISkill{
skillId: number;
name: string;
description: string;
proficiencyLevel: number;
}
@@ -0,0 +1,6 @@
export interface ISocialLinks{
id: number;
gitHub: string;
linkedin: string;
blogUrl: string;
}
+78
View File
@@ -0,0 +1,78 @@
<article class="projects" data-page="projects">
<header>
<h2 class="h2 article-title">Projects</h2>
</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 model.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">
<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 model.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">
<!-- <div class="project-item-icon-box">
<ion-icon name="eye-outline"></ion-icon>
</div> -->
<img src="{{imagesOrigin + project.imagePath}}" 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>
</li>
}
</ul>
</section>
</article>
+6
View File
@@ -0,0 +1,6 @@
import { IProject } from "../models/project.model";
export interface IProjects{
projects: IProject[];
projectsCategories: string[];
}
+7
View File
@@ -0,0 +1,7 @@
.inline{
display: inline;
}
.no-margin{
margin-left: 0px;
}
+23
View File
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Projects } from './projects';
describe('Projects', () => {
let component: Projects;
let fixture: ComponentFixture<Projects>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Projects]
})
.compileComponents();
fixture = TestBed.createComponent(Projects);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
+46
View File
@@ -0,0 +1,46 @@
import { Component, inject, OnInit } from '@angular/core';
import { IProject } from '../models/project.model';
import { BaseComponent } from '../base.component';
import { AdminService } from '../services/admin.service';
import { IProjects } from './projects.model';
import { Subscription } from 'rxjs';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-projects',
templateUrl: './projects.html',
styleUrl: './projects.scss',
imports: [CommonModule]
})
export class Projects extends BaseComponent<IProjects> implements OnInit {
filter = 'All';
projects!: IProject[];
subscription: Subscription = {} as Subscription;
categoryClicked = false;
constructor() {
const svc = inject(AdminService);
super(svc);
}
ngOnInit(): void {
this.getProjects();
}
getProjects() {
this.svc.getProjects(this.candidateId).subscribe((response: IProjects) => {
this.svc.projects = this.svc.projects ?? response;
this.projects = response.projects;
this.assignData(response);
});
}
filterProjects(category: string) {
this.filter = category;
this.projects = this.filter === 'All'
? this.model.projects
: this.model.projects.filter(
(project: IProject) => {
return project.categories.includes(category)
});
}
}
+99
View File
@@ -0,0 +1,99 @@
<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>
</div>
<ol class="timeline-list">
@for (education of model.academics; track education) {
<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>
</div>
<ol class="timeline-list">
@for (experience of model.experiences; track experience) {
<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">
<h3 class="h3 skills-title">My skills</h3>
<ul class="skills-list content-card">
@for (skill of model.skills; track skill) {
<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>
</article>
+9
View File
@@ -0,0 +1,9 @@
import { IAcademic } from "../models/academic.model";
import { IExperience } from "../models/experience.model";
import { ISkill } from "../models/skill.model";
export interface IResume{
academics?: IAcademic[];
experiences?: IExperience[];
skills?: ISkill[];
}
View File
+23
View File
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Resume } from './resume';
describe('Resume', () => {
let component: Resume;
let fixture: ComponentFixture<Resume>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Resume]
})
.compileComponents();
fixture = TestBed.createComponent(Resume);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
+35
View File
@@ -0,0 +1,35 @@
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BaseComponent } from '../base.component';
import { IResume } from './resume.model';
import { AdminService } from '../services/admin.service';
@Component({
selector: 'app-resume',
templateUrl: './resume.html',
styleUrl: './resume.scss',
imports: [CommonModule]
})
export class Resume extends BaseComponent<IResume> implements OnInit {
constructor() {
const svc = inject(AdminService);
super(svc);
console.log("Resume component constructed");
}
ngOnInit(): void {
console.log("Resume component initialized");
if(!this.isDataLoading()) {
this.isDataLoading.set(true);
this.getResume();
}
}
getResume() {
this.svc.getResume(this.candidateId).subscribe((response: IResume) => {
this.svc.resume = this.svc.resume ?? response;
this.assignData(response);
this.isDataLoading.set(false);
});
}
}
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { AdminService } from './admin.service';
describe('AdminService', () => {
let service: AdminService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(AdminService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
+56
View File
@@ -0,0 +1,56 @@
import { Injectable } from '@angular/core';
import { IAbout } from '../about/about.model';
import { ICv } from '../models/cv.model';
import { IContactModel } from '../contact/contact.model';
import { IResume } from '../resume/resume.model';
import { IProjects } from '../projects/projects.model';
import { HttpClient } from '@angular/common/http';
import { Observable, of, Subject } from 'rxjs';
import { environment } from '../../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class AdminService {
private readonly baseUrl = (environment.apiUrl ?? '').replace(/\/+$/, '');
public cv!: ICv;
public about!: IAbout;
public candidateAndSocialLinks!: IContactModel;
public resume!: IResume;
public projects!: IProjects;
constructor(private http: HttpClient) { }
private api(path: string) {
return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
}
getHobbies(candidateId: number): Observable<IAbout>{
if(this.about != null){
return of(this.about);
}
return this.http.get<IAbout>(this.api(`/api/v1/admin/GetHobbies/${candidateId}`));
}
getCandidateWithSocialLinks(candidateId: number): Observable<IContactModel>{
if(this.candidateAndSocialLinks != null){
return of(this.candidateAndSocialLinks);
}
return this.http.get<IContactModel>(this.api(`/api/v1/admin/GetCandidateWithSocialLinks/${candidateId}`));
}
getResume(candidateId: number): Observable<IResume>{
if(this.resume != null){
return of(this.resume);
}
return this.http.get<IResume>(this.api(`/api/v1/admin/GetResume/${candidateId}`));
}
getProjects(candidateId: number): Observable<IProjects>{
if(this.projects != null){
return of(this.projects);
}
return this.http.get<IProjects>(this.api(`/api/v1/admin/GetProjects/${candidateId}`));
}
}
+3 -2
View File
@@ -3,14 +3,15 @@ import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { provideHttpClient, withFetch, withInterceptorsFromDi } from '@angular/common/http';
import { provideHttpClient, withFetch, withInterceptors, withInterceptorsFromDi } from '@angular/common/http';
import { httpInterceptorProviders } from './interceptors';
import { loadingInterceptor } from './interceptors/loading-interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideHttpClient(withInterceptorsFromDi(), withFetch()),
provideHttpClient(withInterceptorsFromDi(), withInterceptors([loadingInterceptor]), withFetch()),
httpInterceptorProviders,
provideRouter(routes), provideClientHydration(withEventReplay())
]
+6 -3
View File
@@ -1,3 +1,6 @@
<main>
<router-outlet></router-outlet>
</main>
<div>
@if(loader.getLoading()){
<app-spinner></app-spinner>
}
<router-outlet></router-outlet>
</div>
+53 -1
View File
@@ -1,6 +1,58 @@
import { Routes } from '@angular/router';
import { OtpComponent } from './auth/otp/otp.component';
import { authGuard } from './guards/auth-guard';
import { AdminLayout } from './layout/admin-layout/admin-layout';
import { About } from './admin/about/about';
import { Resume } from './admin/resume/resume';
import { Projects } from './admin/projects/projects';
const enum AdminRouteTitles {
Login = 'Login',
About = 'About',
Resume = 'Resume',
Projects = 'Projects',
}
export const routes: Routes = [
{ path: '**', pathMatch: 'full', component: OtpComponent }
{
path: '',
redirectTo: 'admin',
pathMatch: 'full'
},
{
path: 'admin',
component: AdminLayout,
title: 'Admin',
children: [
{
path: '',
redirectTo: 'about', // ✔ correct relative redirect
pathMatch: 'full'
},
{
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,
},
],
}
];
+8 -4
View File
@@ -1,12 +1,16 @@
import { Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Component, CUSTOM_ELEMENTS_SCHEMA, inject, signal } from '@angular/core';
import { SpinnerComponent } from './spinner/spinner.component';
import { LoaderService } from './services/loader.service';
import { RouterModule } from "@angular/router";
@Component({
selector: 'app-root',
imports: [RouterOutlet],
imports: [SpinnerComponent, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './app.html',
styleUrl: './app.scss'
styleUrls: ['./app.scss']
})
export class App {
loader = inject(LoaderService);
protected readonly title = signal('portfolio-admin');
}
+99 -10
View File
@@ -1,35 +1,124 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
import { BehaviorSubject, map, Observable, 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);
private accessTokenSub = new BehaviorSubject<string | null>(null);
tokenReady$ = new BehaviorSubject<boolean>(false);
http = inject(HttpClient);
router = inject(Router);
constructor(private http: HttpClient) {}
private readonly storageKey = 'accessToken';
private api(path: string) {
return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
}
sendOtp(email: string): Observable<any> {
// Call on app start, or from guard
async ensureTokenReady(): Promise<void>{
if(this.tokenReady$.value) return;
const stored = this.safeGetToken();
// try to restore from storage
if(stored){
this.accessTokenSub.next(stored);
this.tokenReady$.next(true);
return;
}
// // Optionally: try a silent refresh on startup to restore session using HttpOnly cookie
// try {
// const res = await firstValueFrom(this.refreshToken());
// this.safeSetToken(res.accessToken);
// }
// catch{
// console.warn('Silent token refresh failed on startup');
// }
// finally{
// this.tokenReady$.next(true);
// }
}
safeSetToken(token: string) {
this.accessToken = token;
if (isPlatformBrowser(this.platformId)) {
localStorage.setItem(this.storageKey, token);
}
}
private safeGetToken(): string | null {
if (isPlatformBrowser(this.platformId)) {
return localStorage.getItem(this.storageKey);
}
return null;
}
private safeRemoveToken() {
this.accessToken = null;
if (isPlatformBrowser(this.platformId)) {
localStorage.removeItem(this.storageKey);
}
}
sendOtp(email: string): Observable<unknown> {
const formData = new FormData();
formData.append('email', email);
return this.http.post(this.api('/api/v1/auth/GenerateOtp'), formData);
}
verifyOtp(userId: string, otpCode: string): Observable<any> {
verifyOtp(userId: string, otpCode: string): Observable<void> {
const body = {
UserId: userId,
OtpCode: otpCode
};
return this.http.post(this.api('/api/v1/auth/ValidateOtp'), body);
UserId: userId,
OtpCode: otpCode
};
return this.http.post<ValidateOtpResponse>(this.api('/api/v1/auth/ValidateOtp'), body).pipe(map((response: ValidateOtpResponse) => {
if (response && response.accessToken) {
this.accessToken = response.accessToken;
this.safeSetToken(response.accessToken);
}
}));
}
getApiKey(): string{
getAccessToken(): string | null {
return this.accessToken ?? this.safeGetToken();
}
logout(): Observable<void> {
this.accessToken = null;
this.safeRemoveToken();
this.router.navigate(['/login']);
return of();
}
refreshToken(): Observable<RefreshTokenResponse> {
return this.http.post<RefreshTokenResponse>(this.api('/api/v1/auth/RefreshToken'), {});
}
getApiKey(): string {
return environment.apiKey;
}
isLoggedIn(): boolean {
return this.safeGetToken() != null;
}
}
+31 -20
View File
@@ -1,40 +1,51 @@
<div class="otp-container">
<h2>🔐 Email Verification</h2>
<div class="verify-wrapper">
<div class="verify-card">
<!-- Step 1: Enter Email -->
@if (!isOtpSent()) {
<form [formGroup]="emailForm" (ngSubmit)="sendOtp()">
<h2 class="title">🔐 OTP Verification</h2>
<!-- Step 1: Enter Email -->
@if (!isOtpSent()) {
<form [formGroup]="emailForm" (ngSubmit)="sendOtp()" class="form-section">
<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">
<p class="info-text">
OTP sent to <b>{{ emailForm.value.email }}</b>
</p>
<form [formGroup]="otpForm" (ngSubmit)="verifyOtp()">
<label>Enter 6-digit OTP</label>
<input type="text" maxlength="6" formControlName="otp" placeholder="123456" />
<button type="submit" [disabled]="otpForm.invalid">Verify OTP</button>
<button type="submit" [disabled]="otpForm.invalid">
Verify OTP
</button>
</form>
<button (click)="resendOtp()" [disabled]="countdown() > 0">
Resend OTP @if (countdown() > 0) {
<span>({{ countdown() }}s)</span>
<button class="resend-btn" (click)="resendOtp()" [disabled]="countdown() > 0">
Resend OTP
@if (countdown() > 0) {
<span>({{ countdown() }}s)</span>
}
</button>
</div>
}
}
<!-- Step 3: Success -->
@if (isVerified()) {
<!-- Step 3: Success -->
<!-- @if (isVerified()) {
<div>
<p class="success">✅ Your email has been verified successfully!</p>
<p class="success-msg">{{ message() }}✅</p>
</div>
}
}
<p class="message">{{ message() }}</p>
@if (isError()) {
<p class="error-message">{{ message() }}</p>
} -->
</div>
</div>
+129 -37
View File
@@ -1,47 +1,139 @@
.otp-container {
max-width: 400px;
margin: 60px auto;
padding: 30px;
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
/* Background wrapper */
.verify-wrapper {
width: 100%;
height: 100vh;
background: #0e0e0e;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
/* Card */
.verify-card {
width: 420px;
/* NEW: More contrast from background */
background: var(--bg-gradient-onyx);
border-radius: 18px;
padding: 35px 40px;
/* NEW: Clean gold glow */
box-shadow:
0 0 12px rgba(227, 179, 65, 0.25),
0 10px 35px rgba(0, 0, 0, 0.65);
/* NEW: Gold border highlight */
border: 1px solid rgba(227, 179, 65, 0.15);
/* Slight glass effect */
backdrop-filter: blur(6px);
}
/* Title */
.title {
text-align: center;
color: #fff;
font-size: 24px;
font-weight: 600;
margin-bottom: 30px;
}
/* Labels */
label {
color: #d6d6d6;
font-size: 14px;
margin-bottom: 6px;
display: block;
}
/* Inputs */
input {
width: 100%;
padding: 14px;
background: #111;
border: 1px solid #333;
color: #fff;
border-radius: 10px;
font-size: 15px;
margin-bottom: 18px;
outline: none;
transition: 0.25s;
&:focus {
border-color: #e3b341;
box-shadow: 0 0 5px rgba(227, 179, 65, 0.45);
}
&::placeholder {
color: #777;
}
}
/* Primary buttons */
button {
width: 100%;
padding: 14px;
background: #e3b341;
border: none;
color: #111;
font-size: 16px;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
transition: 0.25s;
text-align: center;
h2 {
margin-bottom: 20px;
&:disabled {
background: #555;
color: #aaa;
cursor: not-allowed;
}
input {
width: 100%;
padding: 10px;
margin: 10px 0;
font-size: 16px;
&:not(:disabled):hover {
background: #f2c85c;
}
}
/* Resend OTP button */
.resend-btn {
margin-top: 12px;
background: transparent !important;
color: #e3b341 !important;
border: 1px solid #e3b341;
font-size: 14px;
&:hover:not(:disabled) {
background: rgba(227, 179, 65, 0.1);
}
button {
width: 100%;
margin-top: 10px;
padding: 10px;
background-color: #007bff;
border: none;
color: white;
font-size: 16px;
border-radius: 8px;
cursor: pointer;
&:disabled {
background: #aaa;
cursor: not-allowed;
}
&:disabled {
border-color: #555;
color: #777;
}
}
.success {
color: green;
font-weight: 600;
}
/* Success message */
.success-msg {
margin-top: 10px;
font-size: 16px;
color: #78ff8c;
text-align: center;
font-weight: 500;
}
.message {
margin-top: 15px;
color: #555;
}
/* Error / general message */
.error-message {
margin-top: 12px;
color: #ff6b6b;
text-align: center;
font-size: 14px;
}
.info-text {
color: #ccc;
font-size: 14px;
margin-bottom: 10px;
}
+25 -9
View File
@@ -1,23 +1,29 @@
import { Component, signal } from '@angular/core';
import { Component, inject, OnInit, signal } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { AuthService } from '../auth.service';
import { ActivatedRoute, Router } from '@angular/router';
@Component({
selector: 'app-otp',
standalone: false,
templateUrl: './otp.component.html',
styleUrls: ['./otp.component.scss']
})
export class OtpComponent {
export class OtpComponent implements OnInit {
emailForm: FormGroup;
otpForm: FormGroup;
isOtpSent = signal(false);
isVerified = signal(false);
isError = signal(false);
message = signal('');
countdown = signal(0);
timer: any;
timer: NodeJS.Timeout | undefined;
returnUrl = '/';
fb: FormBuilder = inject(FormBuilder);
authService: AuthService = inject(AuthService);
router: Router = inject(Router);
route: ActivatedRoute = inject(ActivatedRoute);
constructor(private fb: FormBuilder, private authService: AuthService) {
constructor() {
this.emailForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
});
@@ -25,6 +31,15 @@ export class OtpComponent {
this.otpForm = this.fb.group({
otp: ['', [Validators.required, Validators.pattern(/^[0-9]{6}$/)]],
});
if(this.authService.isLoggedIn()){
this.router.navigateByUrl('/');
}
}
ngOnInit() {
this.returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') || '/';
}
sendOtp() {
@@ -58,15 +73,16 @@ export class OtpComponent {
const { otp: otpCode } = this.otpForm.value;
this.authService.verifyOtp(userId, otpCode).subscribe({
next: (res) => {
next: () => {
this.isVerified.set(true);
this.message.set(res.message || 'OTP verified successfully ✅');
this.router.navigateByUrl(this.returnUrl); // Navigate to dashboard or desired route after successful verification
},
error: (err) => {
this.isError.set(true);
if (err.status === 401 && err.error?.message) {
this.message.set(err.error.message); // "OTP Expired" or "Invalid OTP"
console.log(err.error.message); // "OTP Expired" or "Invalid OTP"
} else {
this.message.set('Something went wrong. Please try again.');
console.log('Something went wrong. Please try again.');
}
}
});
+6 -5
View File
@@ -1,16 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { CanActivateFn } from '@angular/router';
import { AuthGuard } from './auth-guard';
import { authGuard } from './auth-guard';
describe('AuthGuard', () => {
let guard: AuthGuard;
describe('authGuard', () => {
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => authGuard(...guardParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
guard = TestBed.inject(AuthGuard);
});
it('should be created', () => {
expect(guard).toBeTruthy();
expect(executeGuard).toBeTruthy();
});
});
+18 -12
View File
@@ -1,14 +1,20 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, GuardResult, MaybeAsync, RouterStateSnapshot } from '@angular/router';
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../auth/auth.service';
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate {
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): MaybeAsync<GuardResult> {
return true;
export const authGuard: CanActivateFn = async (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
await auth.ensureTokenReady();
// No token → not logged in
if (!auth.isLoggedIn()) {
router.navigate(['admin/login'], {
queryParams: { returnUrl: state.url }
});
return false;
}
}
return true;
};
+85 -12
View File
@@ -1,32 +1,105 @@
import { Injectable } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor
HttpInterceptor,
HttpErrorResponse
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { BehaviorSubject, catchError, filter, Observable, switchMap, take, throwError } from 'rxjs';
import { AuthService } from '../auth/auth.service';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(public authSvc: AuthService) {}
private refreshInProgress = false;
private refreshSubject = new BehaviorSubject<string | null>(null);
authSvc: AuthService = inject(AuthService);
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
let headers = req.headers;
// add API key
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')) {
// 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');
}
const authReq = request.clone({ headers });
return next.handle(authReq);
// add access token
const token = this.authSvc.getAccessToken();
if (token) {
headers = headers.set('Authorization', `Bearer ${token}`);
}
// final cloned request
const clonedRequest = req.clone({
headers,
withCredentials: true
});
return next.handle(clonedRequest).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status !== 401) {
return throwError(() => err);
}
// if refresh is already in progress
if (this.refreshInProgress) {
return this.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.handle(retryReq);
})
);
}
// start refresh
this.refreshInProgress = true;
this.refreshSubject.next(null);
return this.authSvc.refreshToken()!.pipe(
switchMap(res => {
this.refreshInProgress = false;
const newToken = res.accessToken;
this.authSvc.safeSetToken(newToken);
this.refreshSubject.next(newToken);
const retryReq = clonedRequest.clone({
headers: clonedRequest.headers.set('Authorization', `Bearer ${newToken}`),
withCredentials: true
});
return next.handle(retryReq);
}),
catchError(refreshErr => {
this.refreshInProgress = false;
this.refreshSubject.next(null);
this.authSvc.logout();
return throwError(() => refreshErr);
})
);
})
);
}
}
}
+1 -1
View File
@@ -3,5 +3,5 @@ import { AuthInterceptor } from "./auth-interceptor";
export const httpInterceptorProviders = [
{provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true},
{provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true}
]
@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { HttpInterceptorFn } from '@angular/common/http';
import { loadingInterceptor } from './loading-interceptor';
describe('loadingInterceptor', () => {
const interceptor: HttpInterceptorFn = (req, next) =>
TestBed.runInInjectionContext(() => loadingInterceptor(req, next));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(interceptor).toBeTruthy();
});
});
@@ -0,0 +1,26 @@
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";
let totalRequests = 0;
export const loadingInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
const loadingSvc = inject(LoaderService);
totalRequests++;
loadingSvc.setLoading(true);
return next(req).pipe(
finalize(() => {
totalRequests--;
if (totalRequests === 0) {
loadingSvc.setLoading(false);
}
})
);
};
@@ -0,0 +1,31 @@
<main>
@if(isLoggedIn()){
<app-contact></app-contact>
}
<div class="main-content">
@if(isLoggedIn()){
<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>
@@ -0,0 +1,38 @@
import { Routes } from '@angular/router';
import { About } from '../../admin/about/about';
import { Resume } from '../../admin/resume/resume';
import { Projects } from '../../admin/projects/projects';
/**
* Admin layout child routes.
*
* Notes:
* - Uses a dedicated `Routes` constant for better maintainability.
* - Titles are centralized as constants to avoid magic strings.
* - `pathMatch: 'full'` on the default route ensures exact matching.
* - Ready for lazy-loading or guards if needed in future.
*/
const enum AdminRouteTitles {
About = 'About',
Resume = 'Resume',
Projects = 'Projects',
}
export const adminLayoutRoutes: Routes = [
{
path: '',
component: About,
title: AdminRouteTitles.About,
},
{
path: 'resume',
component: Resume,
title: AdminRouteTitles.Resume,
},
{
path: 'projects',
component: Projects,
title: AdminRouteTitles.Projects,
},
];
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AdminLayout } from './admin-layout';
describe('AdminLayout', () => {
let component: AdminLayout;
let fixture: ComponentFixture<AdminLayout>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AdminLayout]
})
.compileComponents();
fixture = TestBed.createComponent(AdminLayout);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,22 @@
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);
isLoggedIn = signal(false);
constructor() {
this.authSvc.tokenReady$.subscribe(ready => {
if (ready) {
this.isLoggedIn.set(!!this.authSvc.getAccessToken());
}
});
console.log("AdminLayout constructed");
}
}
+16
View File
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { LoaderService } from './loader.service';
describe('LoaderService', () => {
let service: LoaderService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(LoaderService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
+16
View File
@@ -0,0 +1,16 @@
import { Injectable, signal } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class LoaderService {
private loading = signal(false);
setLoading(loading: boolean){
this.loading.set(loading);
}
getLoading(): boolean{
return this.loading();
}
}
+3
View File
@@ -0,0 +1,3 @@
<div class="cssload-container">
<div class="cssload-speeding-wheel"></div>
</div>
+65
View File
@@ -0,0 +1,65 @@
.cssload-container {
position: fixed;
width: 100%;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
z-index: 9999;
}
.cssload-speeding-wheel {
content: "";
display: block;
position: absolute;
left: 48%;
top: 40%;
width: 63px;
height: 63px;
margin: 0 auto;
border: 4px solid hsl(45, 54%, 58%);
border-radius: 50%;
border-left-color: transparent;
border-right-color: transparent;
animation: cssload-spin 1s infinite linear;
-o-animation: cssload-spin 1s infinite linear;
-ms-animation: cssload-spin 1s infinite linear;
-webkit-animation: cssload-spin 1s infinite linear;
-moz-animation: cssload-spin 1s infinite linear;
}
@keyframes cssload-spin {
100% {
transform: rotate(360deg);
transform: rotate(360deg);
}
}
@-o-keyframes cssload-spin {
100% {
-o-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@-ms-keyframes cssload-spin {
100% {
-ms-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@-webkit-keyframes cssload-spin {
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@-moz-keyframes cssload-spin {
100% {
-moz-transform: rotate(360deg);
transform: rotate(360deg);
}
}
+23
View File
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SpinnerComponent } from './spinner.component';
describe('SpinnerComponent', () => {
let component: SpinnerComponent;
let fixture: ComponentFixture<SpinnerComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SpinnerComponent]
})
.compileComponents();
fixture = TestBed.createComponent(SpinnerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Component, ViewEncapsulation } from '@angular/core';
@Component({
selector: 'app-spinner',
templateUrl: './spinner.component.html',
styleUrl: './spinner.component.scss'
})
export class SpinnerComponent {
constructor() {
}
}