Refactor code structure for improved readability and maintainability
This commit is contained in:
+13
-1
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
}
|
||||
);
|
||||
Generated
+2159
-3
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+12
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 |
@@ -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.
@@ -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())
|
||||
]
|
||||
|
||||
+5
-2
@@ -1,3 +1,6 @@
|
||||
<main>
|
||||
<div>
|
||||
@if(loader.getLoading()){
|
||||
<app-spinner></app-spinner>
|
||||
}
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
</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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
Vendored
+12
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 |
@@ -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
Reference in New Issue
Block a user