Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IHobby } from "../models/hobby.model";
|
||||
|
||||
export interface IAbout{
|
||||
about: string;
|
||||
hobbies: IHobby[];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.text-style {
|
||||
white-space: pre-line;
|
||||
font-family: var(--ff-poppins);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
img {
|
||||
border-radius: inherit;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface IAcademic{
|
||||
academicId: number;
|
||||
institution: string;
|
||||
startYear: number;
|
||||
endYear: number;
|
||||
degree: string;
|
||||
period: string;
|
||||
degreeSpecialization: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface ICandidate{
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
dob: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
avatar: string;
|
||||
displayName: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface IHobby{
|
||||
hobbyId: number;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IProject } from "../models/project.model";
|
||||
|
||||
export interface IProjects{
|
||||
projects: IProject[];
|
||||
projectsCategories: string[];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.inline{
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.no-margin{
|
||||
margin-left: 0px;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<div class="cssload-container">
|
||||
<div class="cssload-speeding-wheel"></div>
|
||||
</div>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user