Refactor code structure for improved readability and maintainability

This commit is contained in:
Bangara Raju Kottedi 2025-11-15 12:51:32 +05:30
parent 94f4305615
commit d0025d55ef
117 changed files with 5992 additions and 157 deletions

View File

@ -2,7 +2,10 @@
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
"packageManager": "npm",
"schematicCollections": [
"angular-eslint"
]
},
"newProjectRoot": "projects",
"projects": {
@ -92,6 +95,15 @@
"src/styles.scss"
]
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}

43
eslint.config.js Normal file
View File

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

2162
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

0
public/assets/.gitkeep Normal file
View File

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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

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

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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>

View File

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

View File

@ -0,0 +1,4 @@
.text-style {
white-space: pre-line;
font-family: var(--ff-poppins);
}

View File

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

View File

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

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

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>

View File

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

View File

@ -0,0 +1,3 @@
img {
border-radius: inherit;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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[];
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
.inline{
display: inline;
}
.no-margin{
margin-left: 0px;
}

View File

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

View File

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

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>

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

View File

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

View File

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

View File

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

View File

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

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())
]

View File

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

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,
},
],
}
];

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

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

View File

@ -1,28 +1,35 @@
<div class="otp-container">
<h2>🔐 Email Verification</h2>
<div class="verify-wrapper">
<div class="verify-card">
<h2 class="title">🔐 OTP Verification</h2>
<!-- Step 1: Enter Email -->
@if (!isOtpSent()) {
<form [formGroup]="emailForm" (ngSubmit)="sendOtp()">
<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 -->
<!-- Step 2: OTP Input -->
@if (isOtpSent() && !isVerified()) {
<div>
<p>OTP sent to <b>{{ emailForm.value.email }}</b></p>
<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) {
<button class="resend-btn" (click)="resendOtp()" [disabled]="countdown() > 0">
Resend OTP
@if (countdown() > 0) {
<span>({{ countdown() }}s)</span>
}
</button>
@ -30,11 +37,15 @@
}
<!-- Step 3: Success -->
@if (isVerified()) {
<!-- @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>

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);
text-align: center;
h2 {
margin-bottom: 20px;
/* 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: 10px;
margin: 10px 0;
font-size: 16px;
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%;
margin-top: 10px;
padding: 10px;
background-color: #007bff;
padding: 14px;
background: #e3b341;
border: none;
color: white;
color: #111;
font-size: 16px;
border-radius: 8px;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
transition: 0.25s;
text-align: center;
&:disabled {
background: #aaa;
background: #555;
color: #aaa;
cursor: not-allowed;
}
&:not(:disabled):hover {
background: #f2c85c;
}
}
.success {
color: green;
font-weight: 600;
/* 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);
}
.message {
margin-top: 15px;
color: #555;
&:disabled {
border-color: #555;
color: #777;
}
}
/* Success message */
.success-msg {
margin-top: 10px;
font-size: 16px;
color: #78ff8c;
text-align: center;
font-weight: 500;
}
/* Error / general message */
.error-message {
margin-top: 12px;
color: #ff6b6b;
text-align: center;
font-size: 14px;
}
.info-text {
color: #ccc;
font-size: 14px;
margin-bottom: 10px;
}

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.');
}
}
});

View File

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

View File

@ -1,14 +1,20 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, GuardResult, MaybeAsync, RouterStateSnapshot } from '@angular/router';
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../auth/auth.service';
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;
}
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate {
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): MaybeAsync<GuardResult> {
return true;
}
}
};

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

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}
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
<div class="cssload-container">
<div class="cssload-speeding-wheel"></div>
</div>

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

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

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() {
}
}

0
src/assets/.gitkeep Normal file
View File

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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

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

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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