Compare commits
No commits in common. "38f305067a01b1b4416d156d26ce897f4266e645" and "94f4305615956df212d744f241f04c614e6f9f7a" have entirely different histories.
38f305067a
...
94f4305615
14
angular.json
14
angular.json
@ -2,10 +2,7 @@
|
|||||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"cli": {
|
"cli": {
|
||||||
"packageManager": "npm",
|
"packageManager": "npm"
|
||||||
"schematicCollections": [
|
|
||||||
"angular-eslint"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"newProjectRoot": "projects",
|
"newProjectRoot": "projects",
|
||||||
"projects": {
|
"projects": {
|
||||||
@ -95,15 +92,6 @@
|
|||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"lint": {
|
|
||||||
"builder": "@angular-eslint/builder:lint",
|
|
||||||
"options": {
|
|
||||||
"lintFilePatterns": [
|
|
||||||
"src/**/*.ts",
|
|
||||||
"src/**/*.html"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
const eslint = require("@eslint/js");
|
|
||||||
const tseslint = require("typescript-eslint");
|
|
||||||
const angular = require("angular-eslint");
|
|
||||||
|
|
||||||
module.exports = tseslint.config(
|
|
||||||
{
|
|
||||||
files: ["**/*.ts"],
|
|
||||||
extends: [
|
|
||||||
eslint.configs.recommended,
|
|
||||||
...tseslint.configs.recommended,
|
|
||||||
...tseslint.configs.stylistic,
|
|
||||||
...angular.configs.tsRecommended,
|
|
||||||
],
|
|
||||||
processor: angular.processInlineTemplates,
|
|
||||||
rules: {
|
|
||||||
"@angular-eslint/directive-selector": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
type: "attribute",
|
|
||||||
prefix: "app",
|
|
||||||
style: "camelCase",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"@angular-eslint/component-selector": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
type: "element",
|
|
||||||
prefix: "app",
|
|
||||||
style: "kebab-case",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["**/*.html"],
|
|
||||||
extends: [
|
|
||||||
...angular.configs.templateRecommended,
|
|
||||||
...angular.configs.templateAccessibility,
|
|
||||||
],
|
|
||||||
rules: {},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
2162
package-lock.json
generated
2162
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -7,8 +7,7 @@
|
|||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"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": {
|
"prettier": {
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
@ -45,15 +44,12 @@
|
|||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
"@types/node": "^20.17.19",
|
"@types/node": "^20.17.19",
|
||||||
"angular-eslint": "20.6.0",
|
|
||||||
"eslint": "^9.39.0",
|
|
||||||
"jasmine-core": "~5.9.0",
|
"jasmine-core": "~5.9.0",
|
||||||
"karma": "~6.4.0",
|
"karma": "~6.4.0",
|
||||||
"karma-chrome-launcher": "~3.2.0",
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
"karma-coverage": "~2.2.0",
|
"karma-coverage": "~2.2.0",
|
||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.1.0",
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2"
|
||||||
"typescript-eslint": "8.46.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
12
public/assets/css/all.min.css
vendored
12
public/assets/css/all.min.css
vendored
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 8.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 87 KiB |
@ -1,159 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// element toggle function
|
|
||||||
const elementToggleFunc = function (elem) { elem.classList.toggle("active"); }
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// sidebar variables
|
|
||||||
const sidebar = document.querySelector("[data-sidebar]");
|
|
||||||
const sidebarBtn = document.querySelector("[data-sidebar-btn]");
|
|
||||||
|
|
||||||
// sidebar toggle functionality for mobile
|
|
||||||
sidebarBtn.addEventListener("click", function () { elementToggleFunc(sidebar); });
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// testimonials variables
|
|
||||||
const testimonialsItem = document.querySelectorAll("[data-testimonials-item]");
|
|
||||||
const modalContainer = document.querySelector("[data-modal-container]");
|
|
||||||
const modalCloseBtn = document.querySelector("[data-modal-close-btn]");
|
|
||||||
const overlay = document.querySelector("[data-overlay]");
|
|
||||||
|
|
||||||
// modal variable
|
|
||||||
const modalImg = document.querySelector("[data-modal-img]");
|
|
||||||
const modalTitle = document.querySelector("[data-modal-title]");
|
|
||||||
const modalText = document.querySelector("[data-modal-text]");
|
|
||||||
|
|
||||||
// modal toggle function
|
|
||||||
const testimonialsModalFunc = function () {
|
|
||||||
modalContainer.classList.toggle("active");
|
|
||||||
overlay.classList.toggle("active");
|
|
||||||
}
|
|
||||||
|
|
||||||
// add click event to all modal items
|
|
||||||
for (let i = 0; i < testimonialsItem.length; i++) {
|
|
||||||
|
|
||||||
testimonialsItem[i].addEventListener("click", function () {
|
|
||||||
|
|
||||||
modalImg.src = this.querySelector("[data-testimonials-avatar]").src;
|
|
||||||
modalImg.alt = this.querySelector("[data-testimonials-avatar]").alt;
|
|
||||||
modalTitle.innerHTML = this.querySelector("[data-testimonials-title]").innerHTML;
|
|
||||||
modalText.innerHTML = this.querySelector("[data-testimonials-text]").innerHTML;
|
|
||||||
|
|
||||||
testimonialsModalFunc();
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// add click event to modal close button
|
|
||||||
modalCloseBtn.addEventListener("click", testimonialsModalFunc);
|
|
||||||
overlay.addEventListener("click", testimonialsModalFunc);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// custom select variables
|
|
||||||
const select = document.querySelector("[data-select]");
|
|
||||||
const selectItems = document.querySelectorAll("[data-select-item]");
|
|
||||||
const selectValue = document.querySelector("[data-selecct-value]");
|
|
||||||
const filterBtn = document.querySelectorAll("[data-filter-btn]");
|
|
||||||
|
|
||||||
select.addEventListener("click", function () { elementToggleFunc(this); });
|
|
||||||
|
|
||||||
// add event in all select items
|
|
||||||
for (let i = 0; i < selectItems.length; i++) {
|
|
||||||
selectItems[i].addEventListener("click", function () {
|
|
||||||
|
|
||||||
let selectedValue = this.innerText.toLowerCase();
|
|
||||||
selectValue.innerText = this.innerText;
|
|
||||||
elementToggleFunc(select);
|
|
||||||
filterFunc(selectedValue);
|
|
||||||
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// filter variables
|
|
||||||
const filterItems = document.querySelectorAll("[data-filter-item]");
|
|
||||||
|
|
||||||
const filterFunc = function (selectedValue) {
|
|
||||||
|
|
||||||
for (let i = 0; i < filterItems.length; i++) {
|
|
||||||
|
|
||||||
if (selectedValue === "all") {
|
|
||||||
filterItems[i].classList.add("active");
|
|
||||||
} else if (selectedValue === filterItems[i].dataset.category) {
|
|
||||||
filterItems[i].classList.add("active");
|
|
||||||
} else {
|
|
||||||
filterItems[i].classList.remove("active");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// add event in all filter button items for large screen
|
|
||||||
let lastClickedBtn = filterBtn[0];
|
|
||||||
|
|
||||||
for (let i = 0; i < filterBtn.length; i++) {
|
|
||||||
|
|
||||||
filterBtn[i].addEventListener("click", function () {
|
|
||||||
|
|
||||||
let selectedValue = this.innerText.toLowerCase();
|
|
||||||
selectValue.innerText = this.innerText;
|
|
||||||
filterFunc(selectedValue);
|
|
||||||
|
|
||||||
lastClickedBtn.classList.remove("active");
|
|
||||||
this.classList.add("active");
|
|
||||||
lastClickedBtn = this;
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// contact form variables
|
|
||||||
const form = document.querySelector("[data-form]");
|
|
||||||
const formInputs = document.querySelectorAll("[data-form-input]");
|
|
||||||
const formBtn = document.querySelector("[data-form-btn]");
|
|
||||||
|
|
||||||
// add event to all form input field
|
|
||||||
for (let i = 0; i < formInputs.length; i++) {
|
|
||||||
formInputs[i].addEventListener("input", function () {
|
|
||||||
|
|
||||||
// check form validation
|
|
||||||
if (form.checkValidity()) {
|
|
||||||
formBtn.removeAttribute("disabled");
|
|
||||||
} else {
|
|
||||||
formBtn.setAttribute("disabled", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// page navigation variables
|
|
||||||
const navigationLinks = document.querySelectorAll("[data-nav-link]");
|
|
||||||
const pages = document.querySelectorAll("[data-page]");
|
|
||||||
|
|
||||||
// add event to all nav link
|
|
||||||
for (let i = 0; i < navigationLinks.length; i++) {
|
|
||||||
navigationLinks[i].addEventListener("click", function () {
|
|
||||||
|
|
||||||
for (let i = 0; i < pages.length; i++) {
|
|
||||||
if (this.innerHTML.toLowerCase() === pages[i].dataset.page) {
|
|
||||||
pages[i].classList.add("active");
|
|
||||||
navigationLinks[i].classList.add("active");
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
} else {
|
|
||||||
pages[i].classList.remove("active");
|
|
||||||
navigationLinks[i].classList.remove("active");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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.
@ -1,34 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
import { IHobby } from "../models/hobby.model";
|
|
||||||
|
|
||||||
export interface IAbout{
|
|
||||||
about: string;
|
|
||||||
hobbies: IHobby[];
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
.text-style {
|
|
||||||
white-space: pre-line;
|
|
||||||
font-family: var(--ff-poppins);
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { About } from './about';
|
|
||||||
|
|
||||||
describe('About', () => {
|
|
||||||
let component: About;
|
|
||||||
let fixture: ComponentFixture<About>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [About]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(About);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import { ICandidate } from "../models/candidate.model";
|
|
||||||
import { ISocialLinks } from "../models/social-links.model";
|
|
||||||
|
|
||||||
export interface IContactModel {
|
|
||||||
title: string;
|
|
||||||
candidate?: ICandidate;
|
|
||||||
socialLinks?: ISocialLinks;
|
|
||||||
}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
img {
|
|
||||||
border-radius: inherit;
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { Contact } from './contact';
|
|
||||||
|
|
||||||
describe('Contact', () => {
|
|
||||||
let component: Contact;
|
|
||||||
let fixture: ComponentFixture<Contact>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [Contact]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(Contact);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
export interface IAcademic{
|
|
||||||
academicId: number;
|
|
||||||
institution: string;
|
|
||||||
startYear: number;
|
|
||||||
endYear: number;
|
|
||||||
degree: string;
|
|
||||||
period: string;
|
|
||||||
degreeSpecialization: string;
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
export interface ICandidate{
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
dob: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
address: string;
|
|
||||||
avatar: string;
|
|
||||||
displayName: string;
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
import { IAcademic } from "./academic.model";
|
|
||||||
import { ICandidate } from "./candidate.model";
|
|
||||||
import { IExperience } from "./experience.model";
|
|
||||||
import { IHobby } from "./hobby.model";
|
|
||||||
import { IProject } from "./project.model";
|
|
||||||
import { ISkill } from "./skill.model";
|
|
||||||
import { ISocialLinks } from "./social-links.model";
|
|
||||||
|
|
||||||
export interface ICv{
|
|
||||||
resumeId: number;
|
|
||||||
title: string;
|
|
||||||
about: string;
|
|
||||||
candidate: ICandidate;
|
|
||||||
socialLinks: ISocialLinks;
|
|
||||||
academics: IAcademic[];
|
|
||||||
skills: ISkill[];
|
|
||||||
experiences: IExperience[];
|
|
||||||
hobbies: IHobby[];
|
|
||||||
projects: IProject[];
|
|
||||||
projectsCategories: string[];
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
export interface IExperienceDetails{
|
|
||||||
id: number;
|
|
||||||
details: string;
|
|
||||||
order: number;
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import { IExperienceDetails } from "./experience-details.model";
|
|
||||||
|
|
||||||
export interface IExperience{
|
|
||||||
experienceId: number;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
company: string;
|
|
||||||
startYear: string;
|
|
||||||
endYear: string;
|
|
||||||
period: string;
|
|
||||||
details: IExperienceDetails[];
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
export interface IHobby{
|
|
||||||
hobbyId: number;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
icon: string;
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
export interface IProject{
|
|
||||||
projectId: number;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
categories: string[];
|
|
||||||
categoryList: string[];
|
|
||||||
roles: string[];
|
|
||||||
responsibilities: string[];
|
|
||||||
technologiesUsed: string[];
|
|
||||||
imagePath: string;
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
export interface ISkill{
|
|
||||||
skillId: number;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
proficiencyLevel: number;
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
export interface ISocialLinks{
|
|
||||||
id: number;
|
|
||||||
gitHub: string;
|
|
||||||
linkedin: string;
|
|
||||||
blogUrl: string;
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
<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" tabindex="0"
|
|
||||||
(keyup.enter)="categoryClicked = !categoryClicked">
|
|
||||||
|
|
||||||
<button class="filter-select" [ngClass]="{active: categoryClicked}">
|
|
||||||
|
|
||||||
<div class="select-value">{{filter}}</div>
|
|
||||||
|
|
||||||
<div class="select-icon">
|
|
||||||
<i class="fa-regular fa-chevron-down"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<ul class="select-list">
|
|
||||||
|
|
||||||
<li class="select-item">
|
|
||||||
<button (click)="filterProjects('All')">All</button>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
@for (category of 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}}" alt="{{project.name}}" loading="lazy">
|
|
||||||
</figure>
|
|
||||||
|
|
||||||
<h3 class="project-title">{{project.name}}</h3>
|
|
||||||
<div class="project-category">
|
|
||||||
@for (responsibility of project.responsibilities; track responsibility; let i = $index) {
|
|
||||||
<span class="inline">{{i > 0 ? ', ' + responsibility : responsibility}}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</article>
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
import { IProject } from "../models/project.model";
|
|
||||||
|
|
||||||
export interface IProjects{
|
|
||||||
projects: IProject[];
|
|
||||||
projectsCategories: string[];
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
.inline{
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-margin{
|
|
||||||
margin-left: 0px;
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { Projects } from './projects';
|
|
||||||
|
|
||||||
describe('Projects', () => {
|
|
||||||
let component: Projects;
|
|
||||||
let fixture: ComponentFixture<Projects>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [Projects]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(Projects);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
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)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
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[];
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { Resume } from './resume';
|
|
||||||
|
|
||||||
describe('Resume', () => {
|
|
||||||
let component: Resume;
|
|
||||||
let fixture: ComponentFixture<Resume>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [Resume]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(Resume);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
import { Component, inject, OnInit, PLATFORM_ID } from '@angular/core';
|
|
||||||
import { CommonModule, isPlatformBrowser, isPlatformServer } 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 {
|
|
||||||
platformId = inject(PLATFORM_ID);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
const svc = inject(AdminService);
|
|
||||||
super(svc);
|
|
||||||
console.log("Resume component constructed");
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
console.log("Resume component initialized");
|
|
||||||
console.log("Server:", isPlatformServer(this.platformId));
|
|
||||||
console.log("Browser:", isPlatformBrowser(this.platformId));
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { AdminService } from './admin.service';
|
|
||||||
|
|
||||||
describe('AdminService', () => {
|
|
||||||
let service: AdminService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
TestBed.configureTestingModule({});
|
|
||||||
service = TestBed.inject(AdminService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be created', () => {
|
|
||||||
expect(service).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
import { inject, 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 } 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;
|
|
||||||
private http: HttpClient = inject(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}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +1,17 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { provideHttpClient, withFetch, withInterceptors} from '@angular/common/http';
|
|
||||||
import { loadingInterceptor } from './interceptors/loading-interceptor';
|
|
||||||
import { AuthInterceptor } from './interceptors/auth-interceptor';
|
|
||||||
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
||||||
|
import { provideHttpClient, withFetch, withInterceptorsFromDi } from '@angular/common/http';
|
||||||
|
import { httpInterceptorProviders } from './interceptors';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideZonelessChangeDetection(),
|
provideZonelessChangeDetection(),
|
||||||
provideHttpClient(withInterceptors([loadingInterceptor, AuthInterceptor]), withFetch()),
|
provideHttpClient(withInterceptorsFromDi(), withFetch()),
|
||||||
|
httpInterceptorProviders,
|
||||||
provideRouter(routes), provideClientHydration(withEventReplay())
|
provideRouter(routes), provideClientHydration(withEventReplay())
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,3 @@
|
|||||||
<div>
|
<main>
|
||||||
@if(loader.getLoading()){
|
<router-outlet></router-outlet>
|
||||||
<app-spinner></app-spinner>
|
</main>
|
||||||
}
|
|
||||||
<router-outlet></router-outlet>
|
|
||||||
</div>
|
|
||||||
@ -1,53 +1,6 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { OtpComponent } from './auth/otp/otp.component';
|
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 = [
|
export const routes: Routes = [
|
||||||
{
|
{ path: '**', pathMatch: 'full', component: OtpComponent }
|
||||||
path: '',
|
|
||||||
redirectTo: 'admin/about',
|
|
||||||
pathMatch: 'full'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'admin',
|
|
||||||
component: AdminLayout,
|
|
||||||
title: 'Admin',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: 'login',
|
|
||||||
component: OtpComponent,
|
|
||||||
title: AdminRouteTitles.Login
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'about',
|
|
||||||
component: About,
|
|
||||||
canActivate: [authGuard],
|
|
||||||
title: AdminRouteTitles.About,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'resume',
|
|
||||||
component: Resume,
|
|
||||||
canActivate: [authGuard],
|
|
||||||
title: AdminRouteTitles.Resume,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'projects',
|
|
||||||
component: Projects,
|
|
||||||
canActivate: [authGuard],
|
|
||||||
title: AdminRouteTitles.Projects,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -1,19 +1,12 @@
|
|||||||
import { Component, CUSTOM_ELEMENTS_SCHEMA, inject, signal } from '@angular/core';
|
import { Component, signal } from '@angular/core';
|
||||||
import { SpinnerComponent } from './spinner/spinner.component';
|
import { RouterOutlet } from '@angular/router';
|
||||||
import { LoaderService } from './services/loader.service';
|
|
||||||
import { RouterModule } from "@angular/router";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [SpinnerComponent, RouterModule],
|
imports: [RouterOutlet],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrls: ['./app.scss']
|
styleUrl: './app.scss'
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
loader = inject(LoaderService);
|
|
||||||
protected readonly title = signal('portfolio-admin');
|
protected readonly title = signal('portfolio-admin');
|
||||||
constructor(){
|
|
||||||
console.log('🎯 AppComponent initialized', { time: Date.now() });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,23 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { BrowserModule } from '@angular/platform-browser';
|
import { BrowserModule } from '@angular/platform-browser';
|
||||||
|
import { OtpComponent } from './otp/otp.component';
|
||||||
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { AuthInterceptor } from '../interceptors/auth-interceptor';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
OtpComponent
|
||||||
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
|
ReactiveFormsModule
|
||||||
|
],
|
||||||
|
providers:[
|
||||||
|
AuthInterceptor
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class AuthModule { }
|
export class AuthModule { }
|
||||||
|
|||||||
@ -1,141 +1,35 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { BehaviorSubject, map, Observable, of } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
import { isPlatformBrowser } from '@angular/common';
|
|
||||||
import { Router } from '@angular/router';
|
|
||||||
|
|
||||||
interface ValidateOtpResponse {
|
|
||||||
accessToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RefreshTokenResponse {
|
|
||||||
accessToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly baseUrl = (environment.apiUrl ?? '').replace(/\/+$/, '');
|
private readonly baseUrl = (environment.apiUrl ?? '').replace(/\/+$/, '');
|
||||||
private accessToken: string | null = null;
|
|
||||||
private platformId = inject(PLATFORM_ID);
|
|
||||||
public accessTokenSub = new BehaviorSubject<string | null>(null);
|
|
||||||
http = inject(HttpClient);
|
|
||||||
router = inject(Router);
|
|
||||||
|
|
||||||
private readonly storageKey = 'accessToken';
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
constructor() {
|
|
||||||
console.log('🔥 AuthService constructor started');
|
|
||||||
this.accessToken = this.safeGetToken();
|
|
||||||
console.log('🔥 AuthService constructor finished', { accessToken: !!this.accessToken });
|
|
||||||
}
|
|
||||||
|
|
||||||
private api(path: string) {
|
private api(path: string) {
|
||||||
return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
|
return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// // Call on app start, or from guard
|
sendOtp(email: string): Observable<any> {
|
||||||
// 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);
|
|
||||||
// // }
|
|
||||||
// }
|
|
||||||
|
|
||||||
get currentToken(): string | null {
|
|
||||||
return this.safeGetToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
safeSetToken(token: string) {
|
|
||||||
this.accessToken = token;
|
|
||||||
|
|
||||||
if (isPlatformBrowser(this.platformId)) {
|
|
||||||
localStorage.setItem(this.storageKey, token);
|
|
||||||
this.accessTokenSub.next(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private safeGetToken(): string | null {
|
|
||||||
try {
|
|
||||||
if (isPlatformBrowser(this.platformId)) {
|
|
||||||
const token = localStorage.getItem(this.storageKey);
|
|
||||||
this.accessTokenSub.next(token);
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to read from localStorage:', e);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private safeRemoveToken() {
|
|
||||||
this.accessToken = null;
|
|
||||||
|
|
||||||
if (isPlatformBrowser(this.platformId)) {
|
|
||||||
localStorage.removeItem(this.storageKey);
|
|
||||||
this.accessTokenSub.next(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendOtp(email: string): Observable<unknown> {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('email', email);
|
formData.append('email', email);
|
||||||
return this.http.post(this.api('/api/v1/auth/GenerateOtp'), formData);
|
return this.http.post(this.api('/api/v1/auth/GenerateOtp'), formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
verifyOtp(userId: string, otpCode: string): Observable<void> {
|
verifyOtp(userId: string, otpCode: string): Observable<any> {
|
||||||
const body = {
|
const body = {
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
OtpCode: otpCode
|
OtpCode: otpCode
|
||||||
};
|
};
|
||||||
return this.http.post<ValidateOtpResponse>(this.api('/api/v1/auth/ValidateOtp'), body).pipe(map((response: ValidateOtpResponse) => {
|
return this.http.post(this.api('/api/v1/auth/ValidateOtp'), body);
|
||||||
if (response && response.accessToken) {
|
|
||||||
this.accessToken = response.accessToken;
|
|
||||||
this.safeSetToken(response.accessToken);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getAccessToken(): string | null {
|
getApiKey(): string{
|
||||||
return this.accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
return environment.apiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoggedIn(): boolean {
|
|
||||||
return this.safeGetToken() != null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -1,35 +1,28 @@
|
|||||||
<div class="verify-wrapper">
|
<div class="otp-container">
|
||||||
<div class="verify-card">
|
<h2>🔐 Email Verification</h2>
|
||||||
|
|
||||||
<h2 class="title">🔐 OTP Verification</h2>
|
|
||||||
|
|
||||||
<!-- Step 1: Enter Email -->
|
<!-- Step 1: Enter Email -->
|
||||||
@if (!isOtpSent()) {
|
@if (!isOtpSent()) {
|
||||||
<form [formGroup]="emailForm" (ngSubmit)="sendOtp()" class="form-section">
|
<form [formGroup]="emailForm" (ngSubmit)="sendOtp()">
|
||||||
<label for="email">Email Address</label>
|
<label>Email Address</label>
|
||||||
<input id="email" type="email" formControlName="email" placeholder="Enter your email" />
|
<input type="email" formControlName="email" placeholder="Enter your email" />
|
||||||
<button type="submit" [disabled]="emailForm.invalid">Send OTP</button>
|
<button type="submit" [disabled]="emailForm.invalid">Send OTP</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Step 2: OTP Input -->
|
<!-- Step 2: Enter OTP -->
|
||||||
@if (isOtpSent() && !isVerified()) {
|
@if (isOtpSent() && !isVerified()) {
|
||||||
<div class="otp-section">
|
<div>
|
||||||
<p class="info-text">
|
<p>OTP sent to <b>{{ emailForm.value.email }}</b></p>
|
||||||
OTP sent to <b>{{ emailForm.value.email }}</b>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form [formGroup]="otpForm" (ngSubmit)="verifyOtp()">
|
<form [formGroup]="otpForm" (ngSubmit)="verifyOtp()">
|
||||||
<label for="otp">Enter 6-digit OTP</label>
|
<label>Enter 6-digit OTP</label>
|
||||||
<input id="otp" type="text" maxlength="6" formControlName="otp" placeholder="123456" />
|
<input type="text" maxlength="6" formControlName="otp" placeholder="123456" />
|
||||||
<button type="submit" [disabled]="otpForm.invalid">
|
<button type="submit" [disabled]="otpForm.invalid">Verify OTP</button>
|
||||||
Verify OTP
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<button class="resend-btn" (click)="resendOtp()" [disabled]="countdown() > 0">
|
<button (click)="resendOtp()" [disabled]="countdown() > 0">
|
||||||
Resend OTP
|
Resend OTP @if (countdown() > 0) {
|
||||||
@if (countdown() > 0) {
|
|
||||||
<span>({{ countdown() }}s)</span>
|
<span>({{ countdown() }}s)</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
@ -37,15 +30,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<!-- Step 3: Success -->
|
<!-- Step 3: Success -->
|
||||||
<!-- @if (isVerified()) {
|
@if (isVerified()) {
|
||||||
<div>
|
<div>
|
||||||
<p class="success-msg">{{ message() }}✅</p>
|
<p class="success">✅ Your email has been verified successfully!</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (isError()) {
|
<p class="message">{{ message() }}</p>
|
||||||
<p class="error-message">{{ message() }}</p>
|
|
||||||
} -->
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
@ -1,139 +1,47 @@
|
|||||||
/* Background wrapper */
|
.otp-container {
|
||||||
.verify-wrapper {
|
max-width: 400px;
|
||||||
width: 100%;
|
margin: 60px auto;
|
||||||
height: 100vh;
|
padding: 30px;
|
||||||
background: #0e0e0e;
|
background: #fff;
|
||||||
display: flex;
|
border-radius: 16px;
|
||||||
justify-content: center;
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
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;
|
text-align: center;
|
||||||
color: #fff;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Labels */
|
h2 {
|
||||||
label {
|
margin-bottom: 20px;
|
||||||
color: #d6d6d6;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inputs */
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 14px;
|
|
||||||
background: #111;
|
|
||||||
border: 1px solid #333;
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 15px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
outline: none;
|
|
||||||
transition: 0.25s;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: #e3b341;
|
|
||||||
box-shadow: 0 0 5px rgba(227, 179, 65, 0.45);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&::placeholder {
|
input {
|
||||||
color: #777;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Primary buttons */
|
|
||||||
button {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 14px;
|
padding: 10px;
|
||||||
background: #e3b341;
|
margin: 10px 0;
|
||||||
border: none;
|
|
||||||
color: #111;
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
border-radius: 10px;
|
}
|
||||||
font-weight: 600;
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #007bff;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: 0.25s;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
background: #555;
|
background: #aaa;
|
||||||
color: #aaa;
|
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:not(:disabled):hover {
|
|
||||||
background: #f2c85c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Resend OTP button */
|
|
||||||
.resend-btn {
|
|
||||||
margin-top: 12px;
|
|
||||||
background: transparent !important;
|
|
||||||
color: #e3b341 !important;
|
|
||||||
border: 1px solid #e3b341;
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: rgba(227, 179, 65, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
.success {
|
||||||
border-color: #555;
|
color: green;
|
||||||
color: #777;
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin-top: 15px;
|
||||||
|
color: #555;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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,31 +1,23 @@
|
|||||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
import { Component, signal } from '@angular/core';
|
||||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { AuthService } from '../auth.service';
|
import { AuthService } from '../auth.service';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-otp',
|
selector: 'app-otp',
|
||||||
|
standalone: false,
|
||||||
templateUrl: './otp.component.html',
|
templateUrl: './otp.component.html',
|
||||||
styleUrls: ['./otp.component.scss'],
|
styleUrls: ['./otp.component.scss']
|
||||||
imports: [ReactiveFormsModule, CommonModule]
|
|
||||||
})
|
})
|
||||||
export class OtpComponent implements OnInit {
|
export class OtpComponent {
|
||||||
emailForm: FormGroup;
|
emailForm: FormGroup;
|
||||||
otpForm: FormGroup;
|
otpForm: FormGroup;
|
||||||
isOtpSent = signal(false);
|
isOtpSent = signal(false);
|
||||||
isVerified = signal(false);
|
isVerified = signal(false);
|
||||||
isError = signal(false);
|
|
||||||
message = signal('');
|
message = signal('');
|
||||||
countdown = signal(0);
|
countdown = signal(0);
|
||||||
timer: NodeJS.Timeout | undefined;
|
timer: any;
|
||||||
returnUrl = '/';
|
|
||||||
fb: FormBuilder = inject(FormBuilder);
|
|
||||||
authService: AuthService = inject(AuthService);
|
|
||||||
router: Router = inject(Router);
|
|
||||||
route: ActivatedRoute = inject(ActivatedRoute);
|
|
||||||
|
|
||||||
constructor() {
|
constructor(private fb: FormBuilder, private authService: AuthService) {
|
||||||
this.emailForm = this.fb.group({
|
this.emailForm = this.fb.group({
|
||||||
email: ['', [Validators.required, Validators.email]],
|
email: ['', [Validators.required, Validators.email]],
|
||||||
});
|
});
|
||||||
@ -33,15 +25,6 @@ export class OtpComponent implements OnInit {
|
|||||||
this.otpForm = this.fb.group({
|
this.otpForm = this.fb.group({
|
||||||
otp: ['', [Validators.required, Validators.pattern(/^[0-9]{6}$/)]],
|
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() {
|
sendOtp() {
|
||||||
@ -75,16 +58,15 @@ export class OtpComponent implements OnInit {
|
|||||||
const { otp: otpCode } = this.otpForm.value;
|
const { otp: otpCode } = this.otpForm.value;
|
||||||
|
|
||||||
this.authService.verifyOtp(userId, otpCode).subscribe({
|
this.authService.verifyOtp(userId, otpCode).subscribe({
|
||||||
next: () => {
|
next: (res) => {
|
||||||
this.isVerified.set(true);
|
this.isVerified.set(true);
|
||||||
this.router.navigateByUrl(this.returnUrl); // Navigate to dashboard or desired route after successful verification
|
this.message.set(res.message || 'OTP verified successfully ✅');
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.isError.set(true);
|
|
||||||
if (err.status === 401 && err.error?.message) {
|
if (err.status === 401 && err.error?.message) {
|
||||||
console.log(err.error.message); // "OTP Expired" or "Invalid OTP"
|
this.message.set(err.error.message); // "OTP Expired" or "Invalid OTP"
|
||||||
} else {
|
} else {
|
||||||
console.log('Something went wrong. Please try again.');
|
this.message.set('Something went wrong. Please try again.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { CanActivateFn } from '@angular/router';
|
|
||||||
|
|
||||||
import { authGuard } from './auth-guard';
|
import { AuthGuard } from './auth-guard';
|
||||||
|
|
||||||
describe('authGuard', () => {
|
describe('AuthGuard', () => {
|
||||||
const executeGuard: CanActivateFn = (...guardParameters) =>
|
let guard: AuthGuard;
|
||||||
TestBed.runInInjectionContext(() => authGuard(...guardParameters));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({});
|
TestBed.configureTestingModule({});
|
||||||
|
guard = TestBed.inject(AuthGuard);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be created', () => {
|
it('should be created', () => {
|
||||||
expect(executeGuard).toBeTruthy();
|
expect(guard).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import { inject } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { CanActivateFn, Router } from '@angular/router';
|
import { ActivatedRouteSnapshot, CanActivate, GuardResult, MaybeAsync, RouterStateSnapshot } from '@angular/router';
|
||||||
import { AuthService } from '../auth/auth.service';
|
|
||||||
|
|
||||||
export const authGuard: CanActivateFn = (route, state) => {
|
@Injectable({
|
||||||
const auth = inject(AuthService);
|
providedIn: 'root'
|
||||||
const router = inject(Router);
|
})
|
||||||
|
export class AuthGuard implements CanActivate {
|
||||||
|
canActivate(
|
||||||
|
route: ActivatedRouteSnapshot,
|
||||||
|
state: RouterStateSnapshot): MaybeAsync<GuardResult> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return auth.currentToken
|
}
|
||||||
? true
|
|
||||||
: router.parseUrl(`/admin/login?returnUrl=${state.url}`);
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,101 +1,32 @@
|
|||||||
import { inject } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
HttpRequest,
|
HttpRequest,
|
||||||
HttpHandlerFn,
|
HttpHandler,
|
||||||
HttpEvent,
|
HttpEvent,
|
||||||
HttpErrorResponse,
|
HttpInterceptor
|
||||||
HttpInterceptorFn
|
|
||||||
} from '@angular/common/http';
|
} from '@angular/common/http';
|
||||||
import { BehaviorSubject, catchError, filter, Observable, switchMap, take, throwError } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
|
||||||
export const AuthInterceptor: HttpInterceptorFn = (
|
@Injectable()
|
||||||
req: HttpRequest<unknown>,
|
export class AuthInterceptor implements HttpInterceptor {
|
||||||
next: HttpHandlerFn
|
|
||||||
): Observable<HttpEvent<unknown>> => {
|
constructor(public authSvc: AuthService) {}
|
||||||
|
|
||||||
|
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||||
|
const apiKey = this.authSvc.getApiKey();
|
||||||
|
let headers = request.headers;
|
||||||
|
|
||||||
let refreshInProgress = false;
|
|
||||||
const refreshSubject = new BehaviorSubject<string | null>(null);
|
|
||||||
const authSvc: AuthService = inject(AuthService);
|
|
||||||
let headers = req.headers;
|
|
||||||
// add API key
|
|
||||||
const apiKey = authSvc.getApiKey();
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
headers = headers.set('XApiKey', apiKey);
|
headers = headers.set('XApiKey', apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// add content type if needed
|
// Ensure Content-Type is JSON for POST/PUT if missing
|
||||||
if (!(req.body instanceof FormData) &&
|
if (!(request.body instanceof FormData) && !headers.has('Content-Type') && (request.method === 'POST' || request.method === 'PUT')) {
|
||||||
(req.method === 'POST' || req.method === 'PUT') &&
|
|
||||||
!headers.has('Content-Type')) {
|
|
||||||
|
|
||||||
headers = headers.set('Content-Type', 'application/json');
|
headers = headers.set('Content-Type', 'application/json');
|
||||||
}
|
}
|
||||||
|
|
||||||
// add access token
|
const authReq = request.clone({ headers });
|
||||||
const token = authSvc.currentToken;
|
return next.handle(authReq);
|
||||||
if (token) {
|
|
||||||
headers = headers.set('Authorization', `Bearer ${token}`);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// final cloned request
|
|
||||||
const clonedRequest = req.clone({
|
|
||||||
headers,
|
|
||||||
withCredentials: true
|
|
||||||
});
|
|
||||||
|
|
||||||
return next(clonedRequest).pipe(
|
|
||||||
catchError((err: HttpErrorResponse) => {
|
|
||||||
|
|
||||||
if (err.status !== 401) {
|
|
||||||
return throwError(() => err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// if refresh is already in progress
|
|
||||||
if (refreshInProgress) {
|
|
||||||
return refreshSubject.pipe(
|
|
||||||
filter(t => t !== null),
|
|
||||||
take(1),
|
|
||||||
switchMap(newToken => {
|
|
||||||
|
|
||||||
const retryReq = clonedRequest.clone({
|
|
||||||
headers: clonedRequest.headers.set('Authorization', `Bearer ${newToken}`),
|
|
||||||
withCredentials: true
|
|
||||||
});
|
|
||||||
|
|
||||||
return next(retryReq);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// start refresh
|
|
||||||
refreshInProgress = true;
|
|
||||||
refreshSubject.next(null);
|
|
||||||
|
|
||||||
return authSvc.refreshToken()!.pipe(
|
|
||||||
switchMap(res => {
|
|
||||||
|
|
||||||
refreshInProgress = false;
|
|
||||||
const newToken = res.accessToken;
|
|
||||||
authSvc.safeSetToken(newToken);
|
|
||||||
|
|
||||||
refreshSubject.next(newToken);
|
|
||||||
const retryReq = clonedRequest.clone({
|
|
||||||
headers: clonedRequest.headers.set('Authorization', `Bearer ${newToken}`),
|
|
||||||
withCredentials: true
|
|
||||||
});
|
|
||||||
|
|
||||||
return next(retryReq);
|
|
||||||
}),
|
|
||||||
|
|
||||||
catchError(refreshErr => {
|
|
||||||
refreshInProgress = false;
|
|
||||||
refreshSubject.next(null);
|
|
||||||
|
|
||||||
authSvc.logout();
|
|
||||||
return throwError(() => refreshErr);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
7
src/app/interceptors/index.ts
Normal file
7
src/app/interceptors/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { HTTP_INTERCEPTORS } from "@angular/common/http";
|
||||||
|
import { AuthInterceptor } from "./auth-interceptor";
|
||||||
|
|
||||||
|
|
||||||
|
export const httpInterceptorProviders = [
|
||||||
|
{provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true},
|
||||||
|
]
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { HttpInterceptorFn } from '@angular/common/http';
|
|
||||||
|
|
||||||
import { loadingInterceptor } from './loading-interceptor';
|
|
||||||
|
|
||||||
describe('loadingInterceptor', () => {
|
|
||||||
const interceptor: HttpInterceptorFn = (req, next) =>
|
|
||||||
TestBed.runInInjectionContext(() => loadingInterceptor(req, next));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
TestBed.configureTestingModule({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be created', () => {
|
|
||||||
expect(interceptor).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
import { HttpEvent, HttpInterceptorFn, HttpRequest, HttpHandlerFn } from "@angular/common/http";
|
|
||||||
import { inject } from "@angular/core";
|
|
||||||
import { Observable, finalize } from "rxjs";
|
|
||||||
import { LoaderService } from "../services/loader.service";
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
<main>
|
|
||||||
@if(loggedIn()){
|
|
||||||
<app-contact></app-contact>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
|
|
||||||
@if(loggedIn()){
|
|
||||||
<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>
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
import { Routes } from '@angular/router';
|
|
||||||
import { About } from '../../admin/about/about';
|
|
||||||
import { Resume } from '../../admin/resume/resume';
|
|
||||||
import { Projects } from '../../admin/projects/projects';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin layout child routes.
|
|
||||||
*
|
|
||||||
* Notes:
|
|
||||||
* - Uses a dedicated `Routes` constant for better maintainability.
|
|
||||||
* - Titles are centralized as constants to avoid magic strings.
|
|
||||||
* - `pathMatch: 'full'` on the default route ensures exact matching.
|
|
||||||
* - Ready for lazy-loading or guards if needed in future.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const enum AdminRouteTitles {
|
|
||||||
About = 'About',
|
|
||||||
Resume = 'Resume',
|
|
||||||
Projects = 'Projects',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const adminLayoutRoutes: Routes = [
|
|
||||||
{
|
|
||||||
path: '',
|
|
||||||
component: About,
|
|
||||||
title: AdminRouteTitles.About,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'resume',
|
|
||||||
component: Resume,
|
|
||||||
title: AdminRouteTitles.Resume,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'projects',
|
|
||||||
component: Projects,
|
|
||||||
title: AdminRouteTitles.Projects,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { AdminLayout } from './admin-layout';
|
|
||||||
|
|
||||||
describe('AdminLayout', () => {
|
|
||||||
let component: AdminLayout;
|
|
||||||
let fixture: ComponentFixture<AdminLayout>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [AdminLayout]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(AdminLayout);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
|
||||||
import { RouterModule } from '@angular/router';
|
|
||||||
import { Contact } from "../../admin/contact/contact";
|
|
||||||
import { AuthService } from '../../auth/auth.service';
|
|
||||||
@Component({
|
|
||||||
selector: 'app-admin-layout',
|
|
||||||
imports: [RouterModule, Contact],
|
|
||||||
templateUrl: './admin-layout.html',
|
|
||||||
styleUrl: './admin-layout.scss'
|
|
||||||
})
|
|
||||||
export class AdminLayout {
|
|
||||||
authSvc = inject(AuthService);
|
|
||||||
loggedIn = signal(false);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.loggedIn.set(this.authSvc.currentToken !== null);
|
|
||||||
this.authSvc.accessTokenSub.subscribe(token => {
|
|
||||||
this.loggedIn.set(token !== null);
|
|
||||||
});
|
|
||||||
console.log("AdminLayout constructed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { LoaderService } from './loader.service';
|
|
||||||
|
|
||||||
describe('LoaderService', () => {
|
|
||||||
let service: LoaderService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
TestBed.configureTestingModule({});
|
|
||||||
service = TestBed.inject(LoaderService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be created', () => {
|
|
||||||
expect(service).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
<div class="cssload-container">
|
|
||||||
<div class="cssload-speeding-wheel"></div>
|
|
||||||
</div>
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import { Component, ViewEncapsulation } from '@angular/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-spinner',
|
|
||||||
templateUrl: './spinner.component.html',
|
|
||||||
styleUrl: './spinner.component.scss'
|
|
||||||
})
|
|
||||||
export class SpinnerComponent {
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
12
src/assets/css/all.min.css
vendored
12
src/assets/css/all.min.css
vendored
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 8.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 87 KiB |
@ -1,159 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// element toggle function
|
|
||||||
const elementToggleFunc = function (elem) { elem.classList.toggle("active"); }
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// sidebar variables
|
|
||||||
const sidebar = document.querySelector("[data-sidebar]");
|
|
||||||
const sidebarBtn = document.querySelector("[data-sidebar-btn]");
|
|
||||||
|
|
||||||
// sidebar toggle functionality for mobile
|
|
||||||
sidebarBtn.addEventListener("click", function () { elementToggleFunc(sidebar); });
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// testimonials variables
|
|
||||||
const testimonialsItem = document.querySelectorAll("[data-testimonials-item]");
|
|
||||||
const modalContainer = document.querySelector("[data-modal-container]");
|
|
||||||
const modalCloseBtn = document.querySelector("[data-modal-close-btn]");
|
|
||||||
const overlay = document.querySelector("[data-overlay]");
|
|
||||||
|
|
||||||
// modal variable
|
|
||||||
const modalImg = document.querySelector("[data-modal-img]");
|
|
||||||
const modalTitle = document.querySelector("[data-modal-title]");
|
|
||||||
const modalText = document.querySelector("[data-modal-text]");
|
|
||||||
|
|
||||||
// modal toggle function
|
|
||||||
const testimonialsModalFunc = function () {
|
|
||||||
modalContainer.classList.toggle("active");
|
|
||||||
overlay.classList.toggle("active");
|
|
||||||
}
|
|
||||||
|
|
||||||
// add click event to all modal items
|
|
||||||
for (let i = 0; i < testimonialsItem.length; i++) {
|
|
||||||
|
|
||||||
testimonialsItem[i].addEventListener("click", function () {
|
|
||||||
|
|
||||||
modalImg.src = this.querySelector("[data-testimonials-avatar]").src;
|
|
||||||
modalImg.alt = this.querySelector("[data-testimonials-avatar]").alt;
|
|
||||||
modalTitle.innerHTML = this.querySelector("[data-testimonials-title]").innerHTML;
|
|
||||||
modalText.innerHTML = this.querySelector("[data-testimonials-text]").innerHTML;
|
|
||||||
|
|
||||||
testimonialsModalFunc();
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// add click event to modal close button
|
|
||||||
modalCloseBtn.addEventListener("click", testimonialsModalFunc);
|
|
||||||
overlay.addEventListener("click", testimonialsModalFunc);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// custom select variables
|
|
||||||
const select = document.querySelector("[data-select]");
|
|
||||||
const selectItems = document.querySelectorAll("[data-select-item]");
|
|
||||||
const selectValue = document.querySelector("[data-selecct-value]");
|
|
||||||
const filterBtn = document.querySelectorAll("[data-filter-btn]");
|
|
||||||
|
|
||||||
select.addEventListener("click", function () { elementToggleFunc(this); });
|
|
||||||
|
|
||||||
// add event in all select items
|
|
||||||
for (let i = 0; i < selectItems.length; i++) {
|
|
||||||
selectItems[i].addEventListener("click", function () {
|
|
||||||
|
|
||||||
let selectedValue = this.innerText.toLowerCase();
|
|
||||||
selectValue.innerText = this.innerText;
|
|
||||||
elementToggleFunc(select);
|
|
||||||
filterFunc(selectedValue);
|
|
||||||
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// filter variables
|
|
||||||
const filterItems = document.querySelectorAll("[data-filter-item]");
|
|
||||||
|
|
||||||
const filterFunc = function (selectedValue) {
|
|
||||||
|
|
||||||
for (let i = 0; i < filterItems.length; i++) {
|
|
||||||
|
|
||||||
if (selectedValue === "all") {
|
|
||||||
filterItems[i].classList.add("active");
|
|
||||||
} else if (selectedValue === filterItems[i].dataset.category) {
|
|
||||||
filterItems[i].classList.add("active");
|
|
||||||
} else {
|
|
||||||
filterItems[i].classList.remove("active");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// add event in all filter button items for large screen
|
|
||||||
let lastClickedBtn = filterBtn[0];
|
|
||||||
|
|
||||||
for (let i = 0; i < filterBtn.length; i++) {
|
|
||||||
|
|
||||||
filterBtn[i].addEventListener("click", function () {
|
|
||||||
|
|
||||||
let selectedValue = this.innerText.toLowerCase();
|
|
||||||
selectValue.innerText = this.innerText;
|
|
||||||
filterFunc(selectedValue);
|
|
||||||
|
|
||||||
lastClickedBtn.classList.remove("active");
|
|
||||||
this.classList.add("active");
|
|
||||||
lastClickedBtn = this;
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// contact form variables
|
|
||||||
const form = document.querySelector("[data-form]");
|
|
||||||
const formInputs = document.querySelectorAll("[data-form-input]");
|
|
||||||
const formBtn = document.querySelector("[data-form-btn]");
|
|
||||||
|
|
||||||
// add event to all form input field
|
|
||||||
for (let i = 0; i < formInputs.length; i++) {
|
|
||||||
formInputs[i].addEventListener("input", function () {
|
|
||||||
|
|
||||||
// check form validation
|
|
||||||
if (form.checkValidity()) {
|
|
||||||
formBtn.removeAttribute("disabled");
|
|
||||||
} else {
|
|
||||||
formBtn.setAttribute("disabled", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// page navigation variables
|
|
||||||
const navigationLinks = document.querySelectorAll("[data-nav-link]");
|
|
||||||
const pages = document.querySelectorAll("[data-page]");
|
|
||||||
|
|
||||||
// add event to all nav link
|
|
||||||
for (let i = 0; i < navigationLinks.length; i++) {
|
|
||||||
navigationLinks[i].addEventListener("click", function () {
|
|
||||||
|
|
||||||
for (let i = 0; i < pages.length; i++) {
|
|
||||||
if (this.innerHTML.toLowerCase() === pages[i].dataset.page) {
|
|
||||||
pages[i].classList.add("active");
|
|
||||||
navigationLinks[i].classList.add("active");
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
} else {
|
|
||||||
pages[i].classList.remove("active");
|
|
||||||
navigationLinks[i].classList.remove("active");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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
Loading…
Reference in New Issue
Block a user