Compare commits

..

No commits in common. "38f305067a01b1b4416d156d26ce897f4266e645" and "94f4305615956df212d744f241f04c614e6f9f7a" have entirely different histories.

119 changed files with 189 additions and 6018 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -7,8 +7,7 @@
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"serve:ssr:portfolio-admin": "node dist/portfolio-admin/server/server.mjs",
"lint": "ng lint"
"serve:ssr:portfolio-admin": "node dist/portfolio-admin/server/server.mjs"
},
"prettier": {
"printWidth": 100,
@ -45,15 +44,12 @@
"@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-eslint": "8.46.3"
"typescript": "~5.9.2"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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[];
}

View File

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

View File

@ -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[];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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[];
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +1,17 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
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 { provideHttpClient, withFetch, withInterceptorsFromDi } from '@angular/common/http';
import { httpInterceptorProviders } from './interceptors';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideHttpClient(withInterceptors([loadingInterceptor, AuthInterceptor]), withFetch()),
provideHttpClient(withInterceptorsFromDi(), withFetch()),
httpInterceptorProviders,
provideRouter(routes), provideClientHydration(withEventReplay())
]
};

View File

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

View File

@ -1,53 +1,6 @@
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: '',
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,
},
],
}
{ path: '**', pathMatch: 'full', component: OtpComponent }
];

View File

@ -1,19 +1,12 @@
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";
import { Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [SpinnerComponent, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrls: ['./app.scss']
styleUrl: './app.scss'
})
export class App {
loader = inject(LoaderService);
protected readonly title = signal('portfolio-admin');
constructor(){
console.log('🎯 AppComponent initialized', { time: Date.now() });
}
}

View File

@ -1,13 +1,23 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BrowserModule } from '@angular/platform-browser';
import { OtpComponent } from './otp/otp.component';
import { ReactiveFormsModule } from '@angular/forms';
import { AuthInterceptor } from '../interceptors/auth-interceptor';
@NgModule({
declarations: [
OtpComponent
],
imports: [
CommonModule,
BrowserModule,
ReactiveFormsModule
],
providers:[
AuthInterceptor
]
})
export class AuthModule { }

View File

@ -1,141 +1,35 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
import { BehaviorSubject, map, Observable, of } from 'rxjs';
import { Injectable } from '@angular/core';
import { 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);
public accessTokenSub = new BehaviorSubject<string | null>(null);
http = inject(HttpClient);
router = inject(Router);
private readonly storageKey = 'accessToken';
constructor() {
console.log('🔥 AuthService constructor started');
this.accessToken = this.safeGetToken();
console.log('🔥 AuthService constructor finished', { accessToken: !!this.accessToken });
}
constructor(private http: HttpClient) {}
private api(path: string) {
return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
}
// // 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);
// // }
// }
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> {
sendOtp(email: string): Observable<any> {
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<void> {
verifyOtp(userId: string, otpCode: string): Observable<any> {
const body = {
UserId: userId,
OtpCode: otpCode
};
return this.http.post<ValidateOtpResponse>(this.api('/api/v1/auth/ValidateOtp'), body).pipe(map((response: ValidateOtpResponse) => {
if (response && response.accessToken) {
this.accessToken = response.accessToken;
this.safeSetToken(response.accessToken);
}
}));
UserId: userId,
OtpCode: otpCode
};
return this.http.post(this.api('/api/v1/auth/ValidateOtp'), body);
}
getAccessToken(): string | null {
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 {
getApiKey(): string{
return environment.apiKey;
}
isLoggedIn(): boolean {
return this.safeGetToken() != null;
}
}

View File

@ -1,51 +1,40 @@
<div class="verify-wrapper">
<div class="verify-card">
<div class="otp-container">
<h2>🔐 Email Verification</h2>
<h2 class="title">🔐 OTP Verification</h2>
<!-- Step 1: Enter Email -->
@if (!isOtpSent()) {
<form [formGroup]="emailForm" (ngSubmit)="sendOtp()" class="form-section">
<label for="email">Email Address</label>
<input id="email" type="email" formControlName="email" placeholder="Enter your email" />
<!-- Step 1: Enter Email -->
@if (!isOtpSent()) {
<form [formGroup]="emailForm" (ngSubmit)="sendOtp()">
<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: OTP Input -->
@if (isOtpSent() && !isVerified()) {
<div class="otp-section">
<p class="info-text">
OTP sent to <b>{{ emailForm.value.email }}</b>
</p>
<!-- Step 2: Enter OTP -->
@if (isOtpSent() && !isVerified()) {
<div>
<p>OTP sent to <b>{{ emailForm.value.email }}</b></p>
<form [formGroup]="otpForm" (ngSubmit)="verifyOtp()">
<label for="otp">Enter 6-digit OTP</label>
<input id="otp" type="text" maxlength="6" formControlName="otp" placeholder="123456" />
<button type="submit" [disabled]="otpForm.invalid">
Verify OTP
</button>
<label>Enter 6-digit OTP</label>
<input type="text" maxlength="6" formControlName="otp" placeholder="123456" />
<button type="submit" [disabled]="otpForm.invalid">Verify OTP</button>
</form>
<button class="resend-btn" (click)="resendOtp()" [disabled]="countdown() > 0">
Resend OTP
@if (countdown() > 0) {
<span>({{ countdown() }}s)</span>
<button (click)="resendOtp()" [disabled]="countdown() > 0">
Resend OTP @if (countdown() > 0) {
<span>({{ countdown() }}s)</span>
}
</button>
</div>
}
}
<!-- Step 3: Success -->
<!-- @if (isVerified()) {
<!-- Step 3: Success -->
@if (isVerified()) {
<div>
<p class="success-msg">{{ message() }}✅</p>
<p class="success">✅ Your email has been verified successfully!</p>
</div>
}
}
@if (isError()) {
<p class="error-message">{{ message() }}</p>
} -->
</div>
<p class="message">{{ message() }}</p>
</div>

View File

@ -1,139 +1,47 @@
/* Background wrapper */
.verify-wrapper {
width: 100%;
height: 100vh;
background: #0e0e0e;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
/* Card */
.verify-card {
width: 420px;
/* NEW: More contrast from background */
background: var(--bg-gradient-onyx);
border-radius: 18px;
padding: 35px 40px;
/* NEW: Clean gold glow */
box-shadow:
0 0 12px rgba(227, 179, 65, 0.25),
0 10px 35px rgba(0, 0, 0, 0.65);
/* NEW: Gold border highlight */
border: 1px solid rgba(227, 179, 65, 0.15);
/* Slight glass effect */
backdrop-filter: blur(6px);
}
/* Title */
.title {
text-align: center;
color: #fff;
font-size: 24px;
font-weight: 600;
margin-bottom: 30px;
}
/* Labels */
label {
color: #d6d6d6;
font-size: 14px;
margin-bottom: 6px;
display: block;
}
/* Inputs */
input {
width: 100%;
padding: 14px;
background: #111;
border: 1px solid #333;
color: #fff;
border-radius: 10px;
font-size: 15px;
margin-bottom: 18px;
outline: none;
transition: 0.25s;
&:focus {
border-color: #e3b341;
box-shadow: 0 0 5px rgba(227, 179, 65, 0.45);
}
&::placeholder {
color: #777;
}
}
/* Primary buttons */
button {
width: 100%;
padding: 14px;
background: #e3b341;
border: none;
color: #111;
font-size: 16px;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
transition: 0.25s;
.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;
&:disabled {
background: #555;
color: #aaa;
cursor: not-allowed;
h2 {
margin-bottom: 20px;
}
&: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);
input {
width: 100%;
padding: 10px;
margin: 10px 0;
font-size: 16px;
}
&:disabled {
border-color: #555;
color: #777;
button {
width: 100%;
margin-top: 10px;
padding: 10px;
background-color: #007bff;
border: none;
color: white;
font-size: 16px;
border-radius: 8px;
cursor: pointer;
&:disabled {
background: #aaa;
cursor: not-allowed;
}
}
}
/* Success message */
.success-msg {
margin-top: 10px;
font-size: 16px;
color: #78ff8c;
text-align: center;
font-weight: 500;
}
.success {
color: green;
font-weight: 600;
}
/* 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;
.message {
margin-top: 15px;
color: #555;
}
}

View File

@ -1,31 +1,23 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Component, signal } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { AuthService } from '../auth.service';
import { ActivatedRoute, Router } from '@angular/router';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-otp',
standalone: false,
templateUrl: './otp.component.html',
styleUrls: ['./otp.component.scss'],
imports: [ReactiveFormsModule, CommonModule]
styleUrls: ['./otp.component.scss']
})
export class OtpComponent implements OnInit {
export class OtpComponent {
emailForm: FormGroup;
otpForm: FormGroup;
isOtpSent = signal(false);
isVerified = signal(false);
isError = signal(false);
message = signal('');
countdown = signal(0);
timer: NodeJS.Timeout | undefined;
returnUrl = '/';
fb: FormBuilder = inject(FormBuilder);
authService: AuthService = inject(AuthService);
router: Router = inject(Router);
route: ActivatedRoute = inject(ActivatedRoute);
timer: any;
constructor() {
constructor(private fb: FormBuilder, private authService: AuthService) {
this.emailForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
});
@ -33,15 +25,6 @@ export class OtpComponent implements OnInit {
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() {
@ -75,16 +58,15 @@ export class OtpComponent implements OnInit {
const { otp: otpCode } = this.otpForm.value;
this.authService.verifyOtp(userId, otpCode).subscribe({
next: () => {
next: (res) => {
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) => {
this.isError.set(true);
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 {
console.log('Something went wrong. Please try again.');
this.message.set('Something went wrong. Please try again.');
}
}
});

View File

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

View File

@ -1,12 +1,14 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../auth/auth.service';
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, GuardResult, MaybeAsync, RouterStateSnapshot } from '@angular/router';
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.currentToken
? true
: router.parseUrl(`/admin/login?returnUrl=${state.url}`);
};
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate {
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): MaybeAsync<GuardResult> {
return true;
}
}

View File

@ -1,101 +1,32 @@
import { inject } from '@angular/core';
import { Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandlerFn,
HttpHandler,
HttpEvent,
HttpErrorResponse,
HttpInterceptorFn
HttpInterceptor
} from '@angular/common/http';
import { BehaviorSubject, catchError, filter, Observable, switchMap, take, throwError } from 'rxjs';
import { Observable } from 'rxjs';
import { AuthService } from '../auth/auth.service';
export const AuthInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
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) {
headers = headers.set('XApiKey', apiKey);
}
// add content type if needed
if (!(req.body instanceof FormData) &&
(req.method === 'POST' || req.method === 'PUT') &&
!headers.has('Content-Type')) {
// 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')) {
headers = headers.set('Content-Type', 'application/json');
}
// add access token
const token = authSvc.currentToken;
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);
})
);
})
);
};
const authReq = request.clone({ headers });
return next.handle(authReq);
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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