Compare commits
No commits in common. "9065626d40bfea9db11fe1f5d21413d2234186e6" and "94f4305615956df212d744f241f04c614e6f9f7a" have entirely different histories.
9065626d40
...
94f4305615
22
angular.json
22
angular.json
@ -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": {
|
||||
@ -23,7 +20,6 @@
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"outputPath": "dist/portfolio-admin",
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
@ -35,7 +31,12 @@
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
]
|
||||
],
|
||||
"server": "src/main.server.ts",
|
||||
"outputMode": "server",
|
||||
"ssr": {
|
||||
"entry": "src/server.ts"
|
||||
}
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@ -91,15 +92,6 @@
|
||||
"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: {},
|
||||
}
|
||||
);
|
||||
2195
package-lock.json
generated
2195
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@ -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,
|
||||
@ -24,7 +23,6 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "20.3.4",
|
||||
"@angular/cdk": "^20.2.5",
|
||||
"@angular/common": "^20.3.0",
|
||||
"@angular/compiler": "^20.3.0",
|
||||
@ -35,7 +33,6 @@
|
||||
"@angular/platform-server": "^20.3.0",
|
||||
"@angular/router": "^20.3.0",
|
||||
"@angular/ssr": "^20.3.2",
|
||||
"@datorama/akita": "^8.0.1",
|
||||
"express": "^5.1.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
@ -47,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
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,17 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="3%" style="stop-color:hsl(240,1%,25%)"/>
|
||||
<stop offset="97%" style="stop-color:hsl(0,0%,19%)"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="14" fill="url(#bg)"/>
|
||||
<!-- Crown -->
|
||||
<path d="M10 44 L10 24 L22 34 L32 16 L42 34 L54 24 L54 44 Z"
|
||||
fill="hsl(45,100%,72%)" stroke="hsl(45,100%,72%)" stroke-width="1" stroke-linejoin="round"/>
|
||||
<rect x="10" y="44" width="44" height="6" rx="2" fill="hsl(45,100%,72%)"/>
|
||||
<!-- jewels -->
|
||||
<circle cx="22" cy="38" r="2.5" fill="hsl(240,1%,17%)"/>
|
||||
<circle cx="32" cy="35" r="2.5" fill="hsl(240,1%,17%)"/>
|
||||
<circle cx="42" cy="38" r="2.5" fill="hsl(240,1%,17%)"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 805 B |
@ -1,41 +0,0 @@
|
||||
@if (about$ | async; as model) {
|
||||
<article class="about active" data-page="about">
|
||||
<header>
|
||||
<h2 class="h2 article-title">About Me</h2>
|
||||
<button class="edit-btn" (click)="openEdit()" aria-label="Edit About">
|
||||
<i class="fa-light fa-pencil"></i>
|
||||
</button>
|
||||
</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">
|
||||
|
||||
@for (hobby of model.hobbies; track hobby.hobbyId) {
|
||||
<li class="service-item">
|
||||
<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,7 +0,0 @@
|
||||
import { IHobby } from "../models/hobby.model";
|
||||
|
||||
export interface IAbout{
|
||||
about: string;
|
||||
title: string;
|
||||
hobbies: IHobby[];
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
.text-style {
|
||||
white-space: pre-line;
|
||||
font-family: var(--ff-poppins);
|
||||
}
|
||||
|
||||
header{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:flex-start;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: var(--orange-yellow-crayola);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: flex-start;
|
||||
margin-top: -4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: rgba(227, 179, 65, 0.12);
|
||||
}
|
||||
@ -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,89 +0,0 @@
|
||||
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { DynamicPopupComponent } from '../../shared/dynamic-popup/dynamic-popup';
|
||||
import { DynamicFormConfig } from '../../shared/dynamic-form/dynamic-form-config';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { AdminQuery } from '../state/admin.query';
|
||||
import { AdminStateService } from '../state/admin-state.service';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { IHobby } from '../models/hobby.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-about',
|
||||
imports: [CommonModule, MatDialogModule],
|
||||
templateUrl: './about.html',
|
||||
styleUrl: './about.scss'
|
||||
})
|
||||
export class About implements OnInit, OnDestroy {
|
||||
private dialog = inject(MatDialog);
|
||||
private adminQuery = inject(AdminQuery);
|
||||
private adminState = inject(AdminStateService);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
about$ = this.adminQuery.about$;
|
||||
loading$ = this.adminQuery.aboutLoading$;
|
||||
imagesOrigin = environment.apiUrl + '/images/';
|
||||
|
||||
popupConfig?: DynamicFormConfig;
|
||||
popupData: Record<string, unknown> = {};
|
||||
|
||||
ngOnInit(): void {
|
||||
if (!this.adminQuery.getAbout()) {
|
||||
this.adminState.loadAbout()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
openEdit(): void {
|
||||
const currentAbout = this.adminQuery.getAbout();
|
||||
|
||||
this.popupConfig = {
|
||||
title: 'Edit About',
|
||||
submitLabel: 'Save',
|
||||
api: {
|
||||
save: '/api/v1/admin/UpsertHobbies',
|
||||
method: 'POST'
|
||||
},
|
||||
fields: [
|
||||
{ name: 'about', label: 'About', type: 'textarea', required: true },
|
||||
{
|
||||
name: 'hobbies',
|
||||
label: 'Hobbies',
|
||||
type: 'array',
|
||||
itemConfig: [
|
||||
{ name: 'hobbyId', label: 'Hobby ID', type: 'hidden' },
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ name: 'description', label: 'Description', type: 'text', required: true },
|
||||
{ name: 'icon', label: 'Icon (FA class)', type: 'text', placeholder: 'e.g. fa-code' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
this.popupData = {
|
||||
about: currentAbout?.about,
|
||||
hobbies: currentAbout?.hobbies ?? []
|
||||
};
|
||||
|
||||
const ref = this.dialog.open(DynamicPopupComponent, {
|
||||
data: { config: this.popupConfig, data: this.popupData },
|
||||
panelClass: 'dark-popup-panel'
|
||||
});
|
||||
|
||||
ref.afterClosed()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((res: { about: string; hobbies: IHobby[] } | null) => {
|
||||
if (res) {
|
||||
const updated = { ...currentAbout!, about: res.about, hobbies: res.hobbies ?? currentAbout!.hobbies };
|
||||
this.adminState.updateAbout(updated);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
@ -1,132 +0,0 @@
|
||||
@if (contact$ | async; as model) {
|
||||
<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">
|
||||
<div class="name-row">
|
||||
<h1 class="name" title="{{model.candidate?.displayName}}">{{model.candidate?.displayName}}</h1>
|
||||
<button class="edit-btn" (click)="openEdit()" aria-label="Edit Contact">
|
||||
<i class="fa-light fa-pencil"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<hr class="logout-divider" />
|
||||
|
||||
<div class="logout-section">
|
||||
<button class="logout-btn" (click)="logout()">
|
||||
<span class="icon">🔓</span>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</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,86 +0,0 @@
|
||||
img {
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.logout-section {
|
||||
margin-top: 14px;
|
||||
padding-bottom: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
padding: 14px 18px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
|
||||
background: #191919; // darker for better contrast
|
||||
border: 1px solid rgba(227, 179, 65, 0.18); // subtle gold border
|
||||
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
color: #e3b341; // gold text
|
||||
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
|
||||
transition: 0.3s ease;
|
||||
|
||||
/* Subtle depth */
|
||||
box-shadow:
|
||||
inset 0 0 8px rgba(255, 255, 255, 0.03),
|
||||
0 4px 18px rgba(0, 0, 0, 0.45);
|
||||
|
||||
&:hover {
|
||||
background: rgba(227, 179, 65, 0.12);
|
||||
|
||||
border-color: rgba(227, 179, 65, 0.35);
|
||||
|
||||
box-shadow:
|
||||
0 0 12px rgba(227, 179, 65, 0.25),
|
||||
inset 0 0 10px rgba(227, 179, 65, 0.1);
|
||||
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 18px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.logout-divider {
|
||||
border: 0;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
margin: 18px 0 14px 0;
|
||||
}
|
||||
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: var(--orange-yellow-crayola);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
top: -8px;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(227, 179, 65, 0.12);
|
||||
}
|
||||
}
|
||||
@ -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,121 +0,0 @@
|
||||
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AuthService } from '../../auth/auth.service';
|
||||
import { AdminQuery } from '../state/admin.query';
|
||||
import { AdminStateService } from '../state/admin-state.service';
|
||||
import { DynamicPopupComponent } from '../../shared/dynamic-popup/dynamic-popup';
|
||||
import { DynamicFormConfig } from '../../shared/dynamic-form/dynamic-form-config';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { IContactModel } from './contact.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-contact',
|
||||
imports: [CommonModule, MatDialogModule],
|
||||
templateUrl: './contact.html',
|
||||
styleUrl: './contact.scss'
|
||||
})
|
||||
export class Contact implements OnInit, OnDestroy {
|
||||
sideBarExpanded = false;
|
||||
authSvc = inject(AuthService);
|
||||
private dialog = inject(MatDialog);
|
||||
private adminQuery = inject(AdminQuery);
|
||||
private adminState = inject(AdminStateService);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
contact$ = this.adminQuery.contact$;
|
||||
loading$ = this.adminQuery.contactLoading$;
|
||||
imagesOrigin = environment.apiUrl + '/images/';
|
||||
|
||||
popupConfig?: DynamicFormConfig;
|
||||
popupData: Record<string, unknown> = {};
|
||||
|
||||
ngOnInit(): void {
|
||||
if (!this.adminQuery.getContact()) {
|
||||
this.adminState.loadContact()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
openEdit(): void {
|
||||
const current = this.adminQuery.getContact();
|
||||
|
||||
this.popupConfig = {
|
||||
title: 'Edit Contact',
|
||||
submitLabel: 'Save',
|
||||
api: {
|
||||
save: '/api/v1/admin/UpsertContact',
|
||||
method: 'POST'
|
||||
},
|
||||
fields: [
|
||||
{ name: 'title', label: 'Title', type: 'text', required: true },
|
||||
{ name: 'firstName', label: 'First Name', type: 'text', required: true },
|
||||
{ name: 'lastName', label: 'Last Name', type: 'text', required: true },
|
||||
{ name: 'email', label: 'Email', type: 'text', required: true },
|
||||
{ name: 'phone', label: 'Phone', type: 'text', required: true },
|
||||
{ name: 'dob', label: 'Date of Birth', type: 'date' },
|
||||
{ name: 'address', label: 'Address', type: 'textarea' },
|
||||
{ name: 'linkedin', label: 'LinkedIn URL', type: 'text' },
|
||||
{ name: 'gitHub', label: 'GitHub URL', type: 'text' },
|
||||
{ name: 'blogUrl', label: 'Blog URL', type: 'text' }
|
||||
]
|
||||
};
|
||||
|
||||
this.popupData = {
|
||||
title: current?.title,
|
||||
firstName: current?.candidate?.firstName,
|
||||
lastName: current?.candidate?.lastName,
|
||||
email: current?.candidate?.email,
|
||||
phone: current?.candidate?.phone,
|
||||
dob: current?.candidate?.dob,
|
||||
address: current?.candidate?.address,
|
||||
linkedin: current?.socialLinks?.linkedin,
|
||||
gitHub: current?.socialLinks?.gitHub,
|
||||
blogUrl: current?.socialLinks?.blogUrl
|
||||
};
|
||||
|
||||
const ref = this.dialog.open(DynamicPopupComponent, {
|
||||
data: { config: this.popupConfig, data: this.popupData },
|
||||
panelClass: 'dark-popup-panel'
|
||||
});
|
||||
|
||||
ref.afterClosed()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((res: IContactModel) => {
|
||||
if (res) {
|
||||
const updated = {
|
||||
...current!,
|
||||
title: res.title ?? '',
|
||||
candidate: {
|
||||
...current!.candidate!,
|
||||
firstName: res.candidate?.firstName ?? '',
|
||||
lastName: res.candidate?.lastName ?? '',
|
||||
displayName: `${res.candidate?.firstName ?? ''} ${res.candidate?.lastName ?? ''}`,
|
||||
email: res.candidate?.email ?? '',
|
||||
phone: res.candidate?.phone ?? '',
|
||||
dob: res.candidate?.dob ?? '',
|
||||
address: res.candidate?.address ?? ''
|
||||
},
|
||||
socialLinks: {
|
||||
...current!.socialLinks!,
|
||||
linkedin: res.socialLinks?.linkedin ?? '',
|
||||
gitHub: res.socialLinks?.gitHub ?? '',
|
||||
blogUrl: res.socialLinks?.blogUrl ?? ''
|
||||
}
|
||||
};
|
||||
this.adminState.updateContact(updated);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.authSvc.logout();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
@ -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,8 +0,0 @@
|
||||
export interface ICertification {
|
||||
certificationId: number;
|
||||
certificationName: string;
|
||||
issuingOrganization: string;
|
||||
certificationLink: string;
|
||||
issueDate: string;
|
||||
expiryDate?: 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,15 +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;
|
||||
location: string;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
details: IExperienceDetails[];
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
export interface IHobby{
|
||||
hobbyId: number;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
export interface IProject{
|
||||
projectId: number;
|
||||
name: string;
|
||||
description: string;
|
||||
categories: string[];
|
||||
roles: string[];
|
||||
responsibilities: string[];
|
||||
technologiesUsed: string[];
|
||||
imagePath: string;
|
||||
challenges: string;
|
||||
lessonsLearned: string;
|
||||
impact: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
status: string;
|
||||
resumeId: number;
|
||||
}
|
||||
@ -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,101 +0,0 @@
|
||||
<div class="project-detail" role="dialog" aria-labelledby="project-title">
|
||||
<div class="detail-header">
|
||||
<h2 id="project-title">{{ project.name }}</h2>
|
||||
<button class="close-btn" (click)="close()" aria-label="Close">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (project.status) {
|
||||
<span class="status-badge">{{ project.status }}</span>
|
||||
}
|
||||
|
||||
@if (project.description) {
|
||||
<div class="detail-section">
|
||||
<h4>Description</h4>
|
||||
<p>{{ project.description }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (project.roles.length) {
|
||||
<div class="detail-section">
|
||||
<h4>Roles</h4>
|
||||
<div class="tag-list">
|
||||
@for (role of project.roles; track role) {
|
||||
<span class="tag">{{ role }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (project.responsibilities.length) {
|
||||
<div class="detail-section">
|
||||
<h4>Responsibilities</h4>
|
||||
<div class="tag-list">
|
||||
@for (r of project.responsibilities; track r) {
|
||||
<span class="tag">{{ r }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (project.technologiesUsed.length) {
|
||||
<div class="detail-section">
|
||||
<h4>Technologies</h4>
|
||||
<div class="tag-list">
|
||||
@for (tech of project.technologiesUsed; track tech) {
|
||||
<span class="tag tech">{{ tech }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (project.startDate || project.endDate) {
|
||||
<div class="detail-section">
|
||||
<h4>Duration</h4>
|
||||
<p class="duration">
|
||||
@if (project.startDate) {
|
||||
<span>{{ project.startDate | date: 'MMM yyyy' }}</span>
|
||||
}
|
||||
@if (project.startDate && project.endDate) {
|
||||
<span> — </span>
|
||||
}
|
||||
@if (project.endDate) {
|
||||
<span>{{ project.endDate | date: 'MMM yyyy' }}</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (project.challenges) {
|
||||
<div class="detail-section">
|
||||
<h4>Challenges</h4>
|
||||
<p>{{ project.challenges }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (project.lessonsLearned) {
|
||||
<div class="detail-section">
|
||||
<h4>Lessons Learned</h4>
|
||||
<p>{{ project.lessonsLearned }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (project.impact) {
|
||||
<div class="detail-section">
|
||||
<h4>Impact</h4>
|
||||
<p>{{ project.impact }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (project.categories.length) {
|
||||
<div class="detail-section">
|
||||
<h4>Categories</h4>
|
||||
<div class="tag-list">
|
||||
@for (cat of project.categories; track cat) {
|
||||
<span class="tag category">{{ cat }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@ -1,107 +0,0 @@
|
||||
.project-detail {
|
||||
padding: 24px;
|
||||
color: var(--white-1, #fff);
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
background: var(--eerie-black-2, #1e1e1e);
|
||||
border: 1.2px solid hsla(45, 100%, 72%, 0.45);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
color: var(--white-1, #fff);
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--light-gray-70, #aaa);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--white-1, #fff);
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
background: rgba(227, 179, 65, 0.15);
|
||||
color: var(--orange-yellow-crayola, #e3b341);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 16px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--light-gray-70, #999);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
color: var(--white-2, #ddd);
|
||||
white-space: pre-line;
|
||||
}
|
||||
}
|
||||
|
||||
.duration {
|
||||
color: var(--white-2, #ddd);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--white-2, #ddd);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
|
||||
&.tech {
|
||||
background: rgba(100, 180, 255, 0.1);
|
||||
color: #8cc4ff;
|
||||
border-color: rgba(100, 180, 255, 0.15);
|
||||
}
|
||||
|
||||
&.category {
|
||||
background: rgba(227, 179, 65, 0.1);
|
||||
color: var(--orange-yellow-crayola, #e3b341);
|
||||
border-color: rgba(227, 179, 65, 0.15);
|
||||
}
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { IProject } from '../../models/project.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-project-detail-popup',
|
||||
templateUrl: './project-detail-popup.html',
|
||||
styleUrls: ['./project-detail-popup.scss'],
|
||||
standalone: true,
|
||||
imports: [CommonModule, DatePipe]
|
||||
})
|
||||
export class ProjectDetailPopupComponent {
|
||||
private dialogRef = inject(MatDialogRef<ProjectDetailPopupComponent>);
|
||||
project: IProject = inject(MAT_DIALOG_DATA);
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
@ -1,89 +0,0 @@
|
||||
<article class="projects" data-page="projects">
|
||||
|
||||
<header>
|
||||
<h2 class="h2 article-title">Projects</h2>
|
||||
<button class="edit-btn add-btn" (click)="openAddProject()" title="Add Project">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</button>
|
||||
</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 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 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">
|
||||
<button class="project-item-icon-box" (click)="openViewProject(project)" (keyup.enter)="openViewProject(project)" title="View Project">
|
||||
<i class="fa-regular fa-eye"></i>
|
||||
</button>
|
||||
|
||||
<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>
|
||||
<button class="edit-btn project-edit-btn" (click)="openEditProject(project)" title="Edit Project">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
</button>
|
||||
<button class="edit-btn project-delete-btn" (click)="deleteProject(project)" title="Delete Project">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
|
||||
</ul>
|
||||
|
||||
</section>
|
||||
|
||||
</article>
|
||||
@ -1,6 +0,0 @@
|
||||
import { IProject } from "../models/project.model";
|
||||
|
||||
export interface IProjects{
|
||||
projects: IProject[];
|
||||
projectsCategories: string[];
|
||||
}
|
||||
@ -1,97 +0,0 @@
|
||||
.inline{
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.no-margin{
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: var(--orange-yellow-crayola);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: rgba(227, 179, 65, 0.12);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
margin-top: 2px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
font-size: 0.9rem;
|
||||
background: rgba(227, 179, 65, 0.1);
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: rgba(227, 179, 65, 0.22);
|
||||
}
|
||||
|
||||
.project-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.project-edit-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 2;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.project-item:hover .project-edit-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.project-edit-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.7) !important;
|
||||
}
|
||||
|
||||
.project-delete-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 42px;
|
||||
z-index: 2;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #ff6b6b;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.project-item:hover .project-delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.project-delete-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.7) !important;
|
||||
color: #ff4444;
|
||||
}
|
||||
@ -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,223 +0,0 @@
|
||||
import { Component, CUSTOM_ELEMENTS_SCHEMA, inject, OnInit, OnDestroy } from '@angular/core';
|
||||
import { IProject } from '../models/project.model';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { DynamicPopupComponent } from '../../shared/dynamic-popup/dynamic-popup';
|
||||
import { DynamicFormConfig } from '../../shared/dynamic-form/dynamic-form-config';
|
||||
import { ProjectDetailPopupComponent } from './project-detail-popup/project-detail-popup';
|
||||
import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog';
|
||||
import { AdminQuery } from '../state/admin.query';
|
||||
import { AdminStateService } from '../state/admin-state.service';
|
||||
import { AdminService } from '../services/admin.service';
|
||||
import { Subject, takeUntil, filter } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
@Component({
|
||||
selector: 'app-projects',
|
||||
templateUrl: './projects.html',
|
||||
styleUrl: './projects.scss',
|
||||
imports: [CommonModule, MatDialogModule],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class Projects implements OnInit, OnDestroy {
|
||||
filter = 'All';
|
||||
projects: IProject[] = [];
|
||||
projectsCategories: string[] = [];
|
||||
categoryClicked = false;
|
||||
imagesOrigin = environment.apiUrl + '/images/';
|
||||
|
||||
private dialog = inject(MatDialog);
|
||||
private http = inject(HttpClient);
|
||||
private adminQuery = inject(AdminQuery);
|
||||
private adminState = inject(AdminStateService);
|
||||
private adminService = inject(AdminService);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
projects$ = this.adminQuery.projects$;
|
||||
loading$ = this.adminQuery.projectsLoading$;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.projects$
|
||||
.pipe(
|
||||
filter(data => data != null),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe(data => {
|
||||
this.projects = data.projects;
|
||||
this.projectsCategories = data.projectsCategories;
|
||||
});
|
||||
|
||||
// Only fetch if not already in store
|
||||
if (!this.adminQuery.getProjects()) {
|
||||
this.adminState.loadProjects()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
filterProjects(category: string) {
|
||||
this.filter = category;
|
||||
const allProjects = this.adminQuery.getProjects();
|
||||
if (!allProjects) return;
|
||||
|
||||
this.projects = category === 'All'
|
||||
? allProjects.projects
|
||||
: allProjects.projects.filter(
|
||||
(project: IProject) => project.categories.includes(category)
|
||||
);
|
||||
}
|
||||
|
||||
private getProjectFormConfig(title: string): DynamicFormConfig {
|
||||
return {
|
||||
title,
|
||||
submitLabel: 'Save',
|
||||
fields: [
|
||||
{ name: 'projectId', label: 'ID', type: 'hidden' },
|
||||
{ name: 'name', label: 'Project Name', type: 'text', required: true },
|
||||
{ name: 'description', label: 'Description', type: 'textarea' },
|
||||
{ name: 'status', label: 'Status', type: 'text', placeholder: 'e.g. Completed, In Progress' },
|
||||
{ name: 'categories', label: 'Categories', type: 'text', placeholder: 'Comma separated' },
|
||||
{ name: 'roles', label: 'Roles', type: 'text', placeholder: 'Comma separated' },
|
||||
{ name: 'responsibilities', label: 'Responsibilities', type: 'text', placeholder: 'Comma separated' },
|
||||
{ name: 'technologiesUsed', label: 'Technologies Used', type: 'text', placeholder: 'Comma separated' },
|
||||
{ name: 'startDate', label: 'Start Date', type: 'date' },
|
||||
{ name: 'endDate', label: 'End Date', type: 'date' },
|
||||
{ name: 'challenges', label: 'Challenges', type: 'textarea' },
|
||||
{ name: 'lessonsLearned', label: 'Lessons Learned', type: 'textarea' },
|
||||
{ name: 'impact', label: 'Impact', type: 'textarea' }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private projectToFormData(project: IProject): Record<string, unknown> {
|
||||
return {
|
||||
...project,
|
||||
categories: (project.categories ?? []).join(', '),
|
||||
roles: (project.roles ?? []).join(', '),
|
||||
responsibilities: (project.responsibilities ?? []).join(', '),
|
||||
technologiesUsed: (project.technologiesUsed ?? []).join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
private formDataToProject(formVal: Record<string, unknown>): IProject {
|
||||
const toArray = (val: unknown): string[] => {
|
||||
if (!val || typeof val !== 'string') return [];
|
||||
return val.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||||
};
|
||||
return {
|
||||
...formVal,
|
||||
categories: toArray(formVal['categories']),
|
||||
roles: toArray(formVal['roles']),
|
||||
responsibilities: toArray(formVal['responsibilities']),
|
||||
technologiesUsed: toArray(formVal['technologiesUsed'])
|
||||
} as IProject;
|
||||
}
|
||||
|
||||
private saveProject(project: IProject, isNew: boolean): void {
|
||||
const url = `${environment.apiUrl}/api/v1/admin/UpsertProject`;
|
||||
|
||||
this.http.post<IProject>(url, project)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((res: IProject) => {
|
||||
const allProjects = this.adminQuery.getProjects();
|
||||
let updatedList: IProject[];
|
||||
|
||||
if (isNew) {
|
||||
updatedList = [...(allProjects?.projects ?? []), res];
|
||||
} else {
|
||||
updatedList = (allProjects?.projects ?? []).map(p =>
|
||||
p.projectId === res.projectId ? res : p
|
||||
);
|
||||
}
|
||||
|
||||
const categories = [...new Set(updatedList.flatMap(p => p.categories ?? []))];
|
||||
this.adminState.updateProjects({
|
||||
projects: updatedList,
|
||||
projectsCategories: categories.length ? categories : allProjects?.projectsCategories ?? []
|
||||
});
|
||||
this.filter = 'All';
|
||||
});
|
||||
}
|
||||
|
||||
openViewProject(project: IProject): void {
|
||||
this.dialog.open(ProjectDetailPopupComponent, {
|
||||
data: project,
|
||||
panelClass: 'dark-popup-panel',
|
||||
width: '520px',
|
||||
maxHeight: '85vh'
|
||||
});
|
||||
}
|
||||
|
||||
openEditProject(project: IProject): void {
|
||||
const config = this.getProjectFormConfig('Edit Project');
|
||||
const data = this.projectToFormData(project);
|
||||
|
||||
const ref = this.dialog.open(DynamicPopupComponent, {
|
||||
data: { config, data },
|
||||
panelClass: 'dark-popup-panel'
|
||||
});
|
||||
|
||||
ref.afterClosed()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((res: Record<string, unknown> | null) => {
|
||||
if (!res) return;
|
||||
const updated = this.formDataToProject(res);
|
||||
this.saveProject(updated, false);
|
||||
});
|
||||
}
|
||||
|
||||
openAddProject(): void {
|
||||
const config = this.getProjectFormConfig('Add Project');
|
||||
const data: Record<string, unknown> = { projectId: 0 };
|
||||
|
||||
const ref = this.dialog.open(DynamicPopupComponent, {
|
||||
data: { config, data },
|
||||
panelClass: 'dark-popup-panel'
|
||||
});
|
||||
|
||||
ref.afterClosed()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((res: Record<string, unknown> | null) => {
|
||||
if (!res) return;
|
||||
const newProject = this.formDataToProject(res);
|
||||
this.saveProject(newProject, true);
|
||||
});
|
||||
}
|
||||
|
||||
deleteProject(project: IProject): void {
|
||||
const ref = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Delete Project',
|
||||
message: `Are you sure you want to delete "${project.name}"? This action cannot be undone.`
|
||||
},
|
||||
panelClass: 'dark-popup-panel',
|
||||
width: '420px'
|
||||
});
|
||||
|
||||
ref.afterClosed()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((confirmed: boolean) => {
|
||||
if (!confirmed) return;
|
||||
|
||||
this.adminService.deleteProject(project.projectId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
const allProjects = this.adminQuery.getProjects();
|
||||
const updatedList = (allProjects?.projects ?? []).filter(p => p.projectId !== project.projectId);
|
||||
const categories = [...new Set(updatedList.flatMap(p => p.categories ?? []))];
|
||||
|
||||
this.adminState.updateProjects({
|
||||
projects: updatedList,
|
||||
projectsCategories: categories.length ? categories : []
|
||||
});
|
||||
this.filter = 'All';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
@ -1,153 +0,0 @@
|
||||
@if (resume$ | async; as model) {
|
||||
<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>
|
||||
|
||||
<button class="edit-btn" type="button" (click)="openEditEducation()" title="Edit Education">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ol class="timeline-list">
|
||||
|
||||
@for (education of model.academics; track education.academicId) {
|
||||
|
||||
<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>
|
||||
|
||||
<button class="edit-btn" type="button" (click)="openEditExperience()" title="Edit Experience">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ol class="timeline-list">
|
||||
|
||||
@for (experience of model.experiences; track experience.experienceId) {
|
||||
|
||||
<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">
|
||||
|
||||
<div class="title-wrapper">
|
||||
<h3 class="h3 skills-title">My skills</h3>
|
||||
|
||||
<button class="edit-btn" type="button" (click)="openEditSkills()" title="Edit Skills">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="skills-list content-card">
|
||||
|
||||
@for (skill of model.skills; track skill.skillId) {
|
||||
|
||||
<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>
|
||||
|
||||
<section class="timeline">
|
||||
|
||||
<div class="title-wrapper">
|
||||
<div class="icon-box">
|
||||
<i class="fa-light fa-certificate"></i>
|
||||
</div>
|
||||
|
||||
<h3 class="h3">Certifications</h3>
|
||||
|
||||
<button class="edit-btn" type="button" (click)="openEditCertifications()" title="Edit Certifications">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ol class="timeline-list">
|
||||
|
||||
@for (cert of model.certifications; track cert.certificationId) {
|
||||
|
||||
<li class="timeline-item">
|
||||
|
||||
<h4 class="h4 timeline-item-title">{{cert.certificationName}}</h4>
|
||||
|
||||
<span>{{cert.issuingOrganization}}</span>
|
||||
|
||||
@if (cert.certificationLink) {
|
||||
<a class="timeline-link" [href]="cert.certificationLink" target="_blank" rel="noopener">
|
||||
<i class="fa-solid fa-arrow-up-right-from-square"></i> View Certificate
|
||||
</a>
|
||||
}
|
||||
|
||||
</li>
|
||||
|
||||
}
|
||||
|
||||
</ol>
|
||||
|
||||
</section>
|
||||
|
||||
</article>
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
import { IAcademic } from "../models/academic.model";
|
||||
import { ICertification } from "../models/certification.model";
|
||||
import { IExperience } from "../models/experience.model";
|
||||
import { ISkill } from "../models/skill.model";
|
||||
|
||||
export interface IResume{
|
||||
academics?: IAcademic[];
|
||||
experiences?: IExperience[];
|
||||
skills?: ISkill[];
|
||||
certifications?: ICertification[];
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
.title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: var(--orange-yellow-crayola);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: flex-start;
|
||||
margin-top: -4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: rgba(227, 179, 65, 0.12);
|
||||
}
|
||||
|
||||
.timeline-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--orange-yellow-crayola);
|
||||
font-size: var(--fs-6);
|
||||
margin-top: 4px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--vegas-gold);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
@ -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,216 +0,0 @@
|
||||
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { DynamicPopupComponent } from '../../shared/dynamic-popup/dynamic-popup';
|
||||
import { DynamicFormConfig } from '../../shared/dynamic-form/dynamic-form-config';
|
||||
import { AdminQuery } from '../state/admin.query';
|
||||
import { AdminStateService } from '../state/admin-state.service';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { IAcademic } from '../models/academic.model';
|
||||
import { IExperience } from '../models/experience.model';
|
||||
import { ISkill } from '../models/skill.model';
|
||||
import { ICertification } from '../models/certification.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-resume',
|
||||
templateUrl: './resume.html',
|
||||
styleUrl: './resume.scss',
|
||||
imports: [CommonModule, MatDialogModule]
|
||||
})
|
||||
export class Resume implements OnInit, OnDestroy {
|
||||
private dialog = inject(MatDialog);
|
||||
private adminQuery = inject(AdminQuery);
|
||||
private adminState = inject(AdminStateService);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
resume$ = this.adminQuery.resume$;
|
||||
loading$ = this.adminQuery.resumeLoading$;
|
||||
|
||||
ngOnInit(): void {
|
||||
if (!this.adminQuery.getResume()) {
|
||||
this.adminState.loadResume()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
openEditEducation(): void {
|
||||
const resume = this.adminQuery.getResume();
|
||||
|
||||
const config: DynamicFormConfig = {
|
||||
title: 'Edit Education',
|
||||
submitLabel: 'Save',
|
||||
api: {
|
||||
save: '/api/v1/admin/UpsertAcademics',
|
||||
method: 'POST',
|
||||
bodyKey: 'academics'
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'academics',
|
||||
label: 'Education',
|
||||
type: 'array',
|
||||
itemConfig: [
|
||||
{ name: 'academicId', label: 'ID', type: 'hidden' },
|
||||
{ name: 'degree', label: 'Degree', type: 'text', required: true },
|
||||
{ name: 'degreeSpecialization', label: 'Specialization', type: 'text' },
|
||||
{ name: 'institution', label: 'Institution', type: 'text', required: true },
|
||||
{ name: 'startYear', label: 'Start Year', type: 'year' },
|
||||
{ name: 'endYear', label: 'End Year', type: 'year' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const data = { academics: resume?.academics ?? [] };
|
||||
|
||||
const ref = this.dialog.open(DynamicPopupComponent, {
|
||||
data: { config, data },
|
||||
panelClass: 'dark-popup-panel'
|
||||
});
|
||||
|
||||
ref.afterClosed()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((res: IAcademic[] | null) => {
|
||||
if (res) {
|
||||
this.adminState.updateResume({ ...resume!, academics: res });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openEditExperience(): void {
|
||||
const resume = this.adminQuery.getResume();
|
||||
|
||||
const config: DynamicFormConfig = {
|
||||
title: 'Edit Experience',
|
||||
submitLabel: 'Save',
|
||||
api: {
|
||||
save: '/api/v1/admin/UpsertExperiences',
|
||||
method: 'POST',
|
||||
bodyKey: 'experiences'
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'experiences',
|
||||
label: 'Experiences',
|
||||
type: 'array',
|
||||
itemConfig: [
|
||||
{ name: 'experienceId', label: 'ID', type: 'hidden' },
|
||||
{ name: 'title', label: 'Job Title', type: 'text', required: true },
|
||||
{ name: 'company', label: 'Company', type: 'text', required: true },
|
||||
{ name: 'location', label: 'Location', type: 'text' },
|
||||
{ name: 'description', label: 'Description', type: 'textarea' },
|
||||
{ name: 'startDate', label: 'Start Date', type: 'date', required: true },
|
||||
{ name: 'endDate', label: 'End Date', type: 'date' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const data = { experiences: resume?.experiences ?? [] };
|
||||
|
||||
const ref = this.dialog.open(DynamicPopupComponent, {
|
||||
data: { config, data },
|
||||
panelClass: 'dark-popup-panel'
|
||||
});
|
||||
|
||||
ref.afterClosed()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((res: IExperience[] | null) => {
|
||||
if (res) {
|
||||
this.adminState.updateResume({ ...resume!, experiences: res });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openEditSkills(): void {
|
||||
const resume = this.adminQuery.getResume();
|
||||
|
||||
const config: DynamicFormConfig = {
|
||||
title: 'Edit Skills',
|
||||
submitLabel: 'Save',
|
||||
api: {
|
||||
save: '/api/v1/admin/UpsertSkills',
|
||||
method: 'POST',
|
||||
bodyKey: 'skills'
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'skills',
|
||||
label: 'Skills',
|
||||
type: 'array',
|
||||
itemConfig: [
|
||||
{ name: 'skillId', label: 'ID', type: 'hidden' },
|
||||
{ name: 'name', label: 'Skill Name', type: 'text', required: true },
|
||||
{ name: 'description', label: 'Description', type: 'text' },
|
||||
{ name: 'proficiencyLevel', label: 'Proficiency (%)', type: 'number', required: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const data = { skills: resume?.skills ?? [] };
|
||||
|
||||
const ref = this.dialog.open(DynamicPopupComponent, {
|
||||
data: { config, data },
|
||||
panelClass: 'dark-popup-panel'
|
||||
});
|
||||
|
||||
ref.afterClosed()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((res: ISkill[] | null) => {
|
||||
if (res) {
|
||||
this.adminState.updateResume({ ...resume!, skills: res });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openEditCertifications(): void {
|
||||
const resume = this.adminQuery.getResume();
|
||||
|
||||
const config: DynamicFormConfig = {
|
||||
title: 'Edit Certifications',
|
||||
submitLabel: 'Save',
|
||||
api: {
|
||||
save: '/api/v1/admin/UpsertCertifications',
|
||||
method: 'POST',
|
||||
bodyKey: 'certifications'
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'certifications',
|
||||
label: 'Certifications',
|
||||
type: 'array',
|
||||
itemConfig: [
|
||||
{ name: 'certificationId', label: 'ID', type: 'hidden' },
|
||||
{ name: 'certificationName', label: 'Certification Name', type: 'text', required: true },
|
||||
{ name: 'issuingOrganization', label: 'Issuing Organization', type: 'text', required: true },
|
||||
{ name: 'certificationLink', label: 'Link', type: 'text', placeholder: 'https://...' },
|
||||
{ name: 'issueDate', label: 'Issue Date', type: 'date' },
|
||||
{ name: 'expiryDate', label: 'Expiry Date', type: 'date' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const data = { certifications: resume?.certifications ?? [] };
|
||||
|
||||
const ref = this.dialog.open(DynamicPopupComponent, {
|
||||
data: { config, data },
|
||||
panelClass: 'dark-popup-panel'
|
||||
});
|
||||
|
||||
ref.afterClosed()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((res: ICertification[] | null) => {
|
||||
if (res) {
|
||||
this.adminState.updateResume({ ...resume!, certifications: res });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
@ -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,50 +0,0 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { IAbout } from '../about/about.model';
|
||||
import { IContactModel } from '../contact/contact.model';
|
||||
import { IResume } from '../resume/resume.model';
|
||||
import { IProjects } from '../projects/projects.model';
|
||||
import { IProject } from '../models/project.model';
|
||||
import { IHobby } from '../models/hobby.model';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AdminService {
|
||||
private readonly baseUrl = (environment.apiUrl ?? '').replace(/\/+$/, '');
|
||||
private http = inject(HttpClient);
|
||||
|
||||
private api(path: string) {
|
||||
return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||
}
|
||||
|
||||
getHobbies(): Observable<IAbout> {
|
||||
return this.http.get<IAbout>(this.api('/api/v1/admin/GetHobbies'));
|
||||
}
|
||||
|
||||
getCandidateWithSocialLinks(): Observable<IContactModel> {
|
||||
return this.http.get<IContactModel>(this.api('/api/v1/admin/GetCandidateWithSocialLinks'));
|
||||
}
|
||||
|
||||
getResume(): Observable<IResume> {
|
||||
return this.http.get<IResume>(this.api('/api/v1/admin/GetResume'));
|
||||
}
|
||||
|
||||
getProjects(): Observable<IProjects> {
|
||||
return this.http.get<IProjects>(this.api('/api/v1/admin/GetProjects'));
|
||||
}
|
||||
|
||||
upsertProject(project: IProject): Observable<IProject> {
|
||||
return this.http.post<IProject>(this.api('/api/v1/admin/UpsertProject'), project);
|
||||
}
|
||||
|
||||
deleteProject(projectId: number): Observable<void> {
|
||||
return this.http.delete<void>(this.api(`/api/v1/admin/DeleteProject/${projectId}`));
|
||||
}
|
||||
|
||||
upsertHobbies(hobbies: IHobby[]): Observable<IHobby[]> {
|
||||
return this.http.post<IHobby[]>(this.api('/api/v1/admin/UpsertHobbies'), hobbies);
|
||||
}
|
||||
}
|
||||
@ -1,140 +0,0 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { tap } from 'rxjs';
|
||||
import { AdminStore, AdminSection } from './admin.store';
|
||||
import { AdminService } from '../services/admin.service';
|
||||
import { IAbout } from '../about/about.model';
|
||||
import { IHobby } from '../models/hobby.model';
|
||||
import { IContactModel } from '../contact/contact.model';
|
||||
import { IProjects } from '../projects/projects.model';
|
||||
import { IResume } from '../resume/resume.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminStateService {
|
||||
private store = inject(AdminStore);
|
||||
private api = inject(AdminService);
|
||||
|
||||
// ── About ──────────────────────────────────────────────
|
||||
|
||||
loadAbout() {
|
||||
this.store.setSectionLoading('about', true);
|
||||
return this.api.getHobbies().pipe(
|
||||
tap({
|
||||
next: (about: IAbout) => {
|
||||
this.store.update({ about });
|
||||
this.store.setSectionLoading('about', false);
|
||||
this.store.setSectionError('about', null);
|
||||
},
|
||||
error: (err) => {
|
||||
this.store.setSectionLoading('about', false);
|
||||
this.store.setSectionError('about', err.message);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateAbout(about: IAbout) {
|
||||
this.store.update({ about });
|
||||
}
|
||||
|
||||
upsertHobbies(hobbies: IHobby[]) {
|
||||
this.store.setSectionLoading('about', true);
|
||||
return this.api.upsertHobbies(hobbies).pipe(
|
||||
tap({
|
||||
next: (updatedHobbies: IHobby[]) => {
|
||||
this.store.update(state => ({
|
||||
about: state.about ? { ...state.about, hobbies: updatedHobbies } : state.about
|
||||
}));
|
||||
this.store.setSectionLoading('about', false);
|
||||
this.store.setSectionError('about', null);
|
||||
},
|
||||
error: (err) => {
|
||||
this.store.setSectionLoading('about', false);
|
||||
this.store.setSectionError('about', err.message);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ── Contact ────────────────────────────────────────────
|
||||
|
||||
loadContact() {
|
||||
this.store.setSectionLoading('contact', true);
|
||||
return this.api.getCandidateWithSocialLinks().pipe(
|
||||
tap({
|
||||
next: (contact: IContactModel) => {
|
||||
this.store.update({ contact });
|
||||
this.store.setSectionLoading('contact', false);
|
||||
this.store.setSectionError('contact', null);
|
||||
},
|
||||
error: (err) => {
|
||||
this.store.setSectionLoading('contact', false);
|
||||
this.store.setSectionError('contact', err.message);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateContact(contact: IContactModel) {
|
||||
this.store.update({ contact });
|
||||
}
|
||||
|
||||
// ── Projects ───────────────────────────────────────────
|
||||
|
||||
loadProjects() {
|
||||
this.store.setSectionLoading('projects', true);
|
||||
return this.api.getProjects().pipe(
|
||||
tap({
|
||||
next: (projects: IProjects) => {
|
||||
this.store.update({ projects });
|
||||
this.store.setSectionLoading('projects', false);
|
||||
this.store.setSectionError('projects', null);
|
||||
},
|
||||
error: (err) => {
|
||||
this.store.setSectionLoading('projects', false);
|
||||
this.store.setSectionError('projects', err.message);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateProjects(projects: IProjects) {
|
||||
this.store.update({ projects });
|
||||
}
|
||||
|
||||
// ── Resume ─────────────────────────────────────────────
|
||||
|
||||
loadResume() {
|
||||
this.store.setSectionLoading('resume', true);
|
||||
return this.api.getResume().pipe(
|
||||
tap({
|
||||
next: (resume: IResume) => {
|
||||
this.store.update({ resume });
|
||||
this.store.setSectionLoading('resume', false);
|
||||
this.store.setSectionError('resume', null);
|
||||
},
|
||||
error: (err) => {
|
||||
this.store.setSectionLoading('resume', false);
|
||||
this.store.setSectionError('resume', err.message);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateResume(resume: IResume) {
|
||||
this.store.update({ resume });
|
||||
}
|
||||
|
||||
// ── Reset helpers ──────────────────────────────────────
|
||||
|
||||
resetSection(section: AdminSection) {
|
||||
this.store.update(state => ({
|
||||
[section]: null,
|
||||
loading: { ...state.loading, [section]: false },
|
||||
error: { ...state.error, [section]: null }
|
||||
}));
|
||||
}
|
||||
|
||||
resetAll() {
|
||||
this.store.reset();
|
||||
}
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Query } from '@datorama/akita';
|
||||
import { AdminStore, AdminState } from './admin.store';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminQuery extends Query<AdminState> {
|
||||
protected override store = inject(AdminStore);
|
||||
|
||||
constructor() {
|
||||
super(inject(AdminStore));
|
||||
}
|
||||
|
||||
// About
|
||||
about$ = this.select('about');
|
||||
aboutLoading$ = this.select(s => s.loading.about);
|
||||
aboutError$ = this.select(s => s.error.about);
|
||||
|
||||
// Contact
|
||||
contact$ = this.select('contact');
|
||||
contactLoading$ = this.select(s => s.loading.contact);
|
||||
contactError$ = this.select(s => s.error.contact);
|
||||
|
||||
// Projects
|
||||
projects$ = this.select('projects');
|
||||
projectsLoading$ = this.select(s => s.loading.projects);
|
||||
projectsError$ = this.select(s => s.error.projects);
|
||||
|
||||
// Resume
|
||||
resume$ = this.select('resume');
|
||||
resumeLoading$ = this.select(s => s.loading.resume);
|
||||
resumeError$ = this.select(s => s.error.resume);
|
||||
|
||||
getAbout() {
|
||||
return this.getValue().about;
|
||||
}
|
||||
|
||||
getContact() {
|
||||
return this.getValue().contact;
|
||||
}
|
||||
|
||||
getProjects() {
|
||||
return this.getValue().projects;
|
||||
}
|
||||
|
||||
getResume() {
|
||||
return this.getValue().resume;
|
||||
}
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Store, StoreConfig } from '@datorama/akita';
|
||||
import { IAbout } from '../about/about.model';
|
||||
import { IContactModel } from '../contact/contact.model';
|
||||
import { IProjects } from '../projects/projects.model';
|
||||
import { IResume } from '../resume/resume.model';
|
||||
|
||||
export type AdminSection = 'about' | 'contact' | 'projects' | 'resume';
|
||||
|
||||
export interface AdminState {
|
||||
about: IAbout | null;
|
||||
contact: IContactModel | null;
|
||||
projects: IProjects | null;
|
||||
resume: IResume | null;
|
||||
loading: Record<AdminSection, boolean>;
|
||||
error: Record<AdminSection, string | null>;
|
||||
}
|
||||
|
||||
export function createInitialState(): AdminState {
|
||||
return {
|
||||
about: null,
|
||||
contact: null,
|
||||
projects: null,
|
||||
resume: null,
|
||||
loading: { about: false, contact: false, projects: false, resume: false },
|
||||
error: { about: null, contact: null, projects: null, resume: null }
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@StoreConfig({ name: 'admin', resettable: true })
|
||||
export class AdminStore extends Store<AdminState> {
|
||||
constructor() {
|
||||
super(createInitialState());
|
||||
}
|
||||
|
||||
setSectionLoading(section: AdminSection, loading: boolean) {
|
||||
this.update(state => ({
|
||||
loading: { ...state.loading, [section]: loading }
|
||||
}));
|
||||
}
|
||||
|
||||
setSectionError(section: AdminSection, error: string | null) {
|
||||
this.update(state => ({
|
||||
error: { ...state.error, [section]: error }
|
||||
}));
|
||||
}
|
||||
}
|
||||
12
src/app/app.config.server.ts
Normal file
12
src/app/app.config.server.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
|
||||
import { provideServerRendering, withRoutes } from '@angular/ssr';
|
||||
import { appConfig } from './app.config';
|
||||
import { serverRoutes } from './app.routes.server';
|
||||
|
||||
const serverConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideServerRendering(withRoutes(serverRoutes))
|
||||
]
|
||||
};
|
||||
|
||||
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
||||
@ -1,17 +1,17 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
|
||||
import { provideAnimations } from '@angular/platform-browser/animations';
|
||||
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(),
|
||||
provideAnimations(),
|
||||
provideHttpClient(withInterceptors([loadingInterceptor, AuthInterceptor]), withFetch()),
|
||||
provideRouter(routes)
|
||||
provideHttpClient(withInterceptorsFromDi(), withFetch()),
|
||||
httpInterceptorProviders,
|
||||
provideRouter(routes), provideClientHydration(withEventReplay())
|
||||
]
|
||||
};
|
||||
|
||||
@ -1,6 +1,3 @@
|
||||
<div>
|
||||
@if(loading()){
|
||||
<app-spinner></app-spinner>
|
||||
}
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
<main>
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
8
src/app/app.routes.server.ts
Normal file
8
src/app/app.routes.server.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { RenderMode, ServerRoute } from '@angular/ssr';
|
||||
|
||||
export const serverRoutes: ServerRoute[] = [
|
||||
{
|
||||
path: '**',
|
||||
renderMode: RenderMode.Prerender
|
||||
}
|
||||
];
|
||||
@ -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: 'about',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
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 }
|
||||
];
|
||||
|
||||
|
||||
@ -1,17 +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');
|
||||
protected readonly loading = this.loader.isLoading;
|
||||
}
|
||||
|
||||
@ -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 { }
|
||||
|
||||
@ -1,128 +1,35 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
|
||||
import { BehaviorSubject, map, Observable } 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';
|
||||
tokenReady$ = new BehaviorSubject<boolean | null>(null);
|
||||
|
||||
constructor() {
|
||||
this.accessToken = this.safeGetToken();
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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(): void {
|
||||
this.http.post<void>(this.api('/api/v1/auth/logout'), {}).subscribe();
|
||||
this.accessToken = null;
|
||||
this.safeRemoveToken();
|
||||
this.router.navigateByUrl('login');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,55 +1,40 @@
|
||||
<div class="verify-wrapper">
|
||||
<div class="verify-card">
|
||||
<div class="otp-container">
|
||||
<h2>🔐 Email Verification</h2>
|
||||
|
||||
@if(!isOtpSent()){
|
||||
<h2 class="title">🔐 Login</h2>
|
||||
}
|
||||
|
||||
@if(isOtpSent() && !isVerified()){
|
||||
<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: Enter OTP -->
|
||||
@if (isOtpSent() && !isVerified()) {
|
||||
<div>
|
||||
<p>OTP sent to <b>{{ emailForm.value.email }}</b></p>
|
||||
|
||||
<!-- Step 2: OTP Input -->
|
||||
@if (isOtpSent() && !isVerified()) {
|
||||
<div class="otp-section">
|
||||
<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>
|
||||
}
|
||||
}
|
||||
|
||||
@if (isError()) {
|
||||
<div class="error-banner">
|
||||
<i class="fa-solid fa-circle-exclamation"></i>
|
||||
<span>{{ message() }}</span>
|
||||
<!-- Step 3: Success -->
|
||||
@if (isVerified()) {
|
||||
<div>
|
||||
<p class="success">✅ Your email has been verified successfully!</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!isError() && isOtpSent() && !isVerified()) {
|
||||
<div class="success-banner">
|
||||
<i class="fa-solid fa-circle-check"></i>
|
||||
<span>{{ message() }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<p class="message">{{ message() }}</p>
|
||||
</div>
|
||||
@ -1,193 +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;
|
||||
}
|
||||
|
||||
/* Error / general message */
|
||||
.error-message {
|
||||
margin-top: 12px;
|
||||
color: #ff6b6b;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ff6b6b;
|
||||
font-size: 13px;
|
||||
margin: 6px 0 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 80, 80, 0.1);
|
||||
border: 1px solid rgba(255, 80, 80, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
margin: 10px 0 10px;
|
||||
color: #ff6b6b;
|
||||
font-size: 13px;
|
||||
animation: shakeError 0.4s ease;
|
||||
|
||||
i {
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
.success {
|
||||
color: green;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shakeError {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-6px); }
|
||||
40% { transform: translateX(6px); }
|
||||
60% { transform: translateX(-4px); }
|
||||
80% { transform: translateX(4px); }
|
||||
}
|
||||
|
||||
.info-text {
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Success banner for OTP sent */
|
||||
.success-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(120, 255, 140, 0.07);
|
||||
border: 1px solid rgba(120, 255, 140, 0.25);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
margin: 10px 0 10px;
|
||||
color: #78ff8c;
|
||||
font-size: 13px;
|
||||
|
||||
i {
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
color: #78ff8c;
|
||||
.message {
|
||||
margin-top: 15px;
|
||||
color: #555;
|
||||
}
|
||||
}
|
||||
@ -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,42 +25,21 @@ export class OtpComponent implements OnInit {
|
||||
this.otpForm = this.fb.group({
|
||||
otp: ['', [Validators.required, Validators.pattern(/^[0-9]{6}$/)]],
|
||||
});
|
||||
|
||||
if(this.authService.isLoggedIn()){
|
||||
this.router.navigate(['']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ngOnInit() {
|
||||
this.returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') || '';
|
||||
}
|
||||
|
||||
sendOtp() {
|
||||
if (this.emailForm.invalid) return;
|
||||
const email = this.emailForm.value.email;
|
||||
|
||||
this.authService.sendOtp(email).subscribe({
|
||||
next: () => {
|
||||
this.isOtpSent.set(true);
|
||||
this.isError.set(false);
|
||||
this.message.set(`OTP sent to ${email}.`);
|
||||
this.startTimer(30); // 30 seconds countdown
|
||||
},
|
||||
error: (err) => {
|
||||
this.isError.set(true);
|
||||
if (err.status === 404) {
|
||||
this.message.set('Email not found. Please check and try again.');
|
||||
} else {
|
||||
this.message.set('Failed to send OTP. Please try again.');
|
||||
}
|
||||
}
|
||||
this.authService.sendOtp(email).subscribe(() => {
|
||||
this.isOtpSent.set(true);
|
||||
this.message.set('OTP sent successfully!');
|
||||
this.startTimer(30); // 30 seconds countdown
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
resendOtp() {
|
||||
if (this.countdown() > 0) return;
|
||||
this.otpForm.reset();
|
||||
this.sendOtp();
|
||||
}
|
||||
|
||||
@ -87,14 +58,13 @@ 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) {
|
||||
this.message.set(err.error.message);
|
||||
this.message.set(err.error.message); // "OTP Expired" or "Invalid OTP"
|
||||
} else {
|
||||
this.message.set('Something went wrong. Please try again.');
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,20 +1,14 @@
|
||||
import { inject, PLATFORM_ID } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
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 platformId = inject(PLATFORM_ID);
|
||||
|
||||
// During SSR there is no localStorage — skip the guard and let the client enforce auth after hydration
|
||||
if (!isPlatformBrowser(platformId)) {
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthGuard implements CanActivate {
|
||||
canActivate(
|
||||
route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot): MaybeAsync<GuardResult> {
|
||||
return true;
|
||||
}
|
||||
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
return auth.currentToken
|
||||
? true
|
||||
: router.parseUrl(`login?returnUrl=${state.url}`);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@ -1,124 +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';
|
||||
|
||||
let refreshInProgress = false;
|
||||
const refreshSubject = new BehaviorSubject<string | null>(null);
|
||||
@Injectable()
|
||||
export class AuthInterceptor implements HttpInterceptor {
|
||||
|
||||
export const AuthInterceptor: HttpInterceptorFn = (
|
||||
req: HttpRequest<unknown>,
|
||||
next: HttpHandlerFn
|
||||
): Observable<HttpEvent<unknown>> => {
|
||||
constructor(public authSvc: AuthService) {}
|
||||
|
||||
const authSvc: AuthService = inject(AuthService);
|
||||
let headers = req.headers;
|
||||
// add API key
|
||||
const apiKey = authSvc.getApiKey();
|
||||
if (apiKey) {
|
||||
headers = headers.set('XApiKey', apiKey);
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
const apiKey = this.authSvc.getApiKey();
|
||||
let headers = request.headers;
|
||||
|
||||
if (apiKey) {
|
||||
headers = headers.set('XApiKey', apiKey);
|
||||
}
|
||||
|
||||
// Ensure Content-Type is JSON for POST/PUT if missing
|
||||
if (!(request.body instanceof FormData) && !headers.has('Content-Type') && (request.method === 'POST' || request.method === 'PUT')) {
|
||||
headers = headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const authReq = request.clone({ headers });
|
||||
return next.handle(authReq);
|
||||
}
|
||||
|
||||
// add content type if needed
|
||||
if (!(req.body instanceof FormData) &&
|
||||
(req.method === 'POST' || req.method === 'PUT') &&
|
||||
!headers.has('Content-Type')) {
|
||||
|
||||
headers = headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Don't attempt token refresh for auth endpoints
|
||||
const isAuthUrl = req.url.includes('/auth/');
|
||||
if (isAuthUrl) {
|
||||
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);
|
||||
|
||||
const refresh$ = authSvc.refreshToken();
|
||||
|
||||
// refreshToken() must return an Observable. Otherwise handle gracefully
|
||||
if (!refresh$) {
|
||||
refreshInProgress = false;
|
||||
authSvc.logout();
|
||||
return throwError(() => err);
|
||||
}
|
||||
|
||||
return refresh$.pipe(
|
||||
|
||||
switchMap(res => {
|
||||
refreshInProgress = false;
|
||||
|
||||
const newToken = res?.accessToken;
|
||||
|
||||
if (!newToken) {
|
||||
authSvc.logout();
|
||||
return throwError(() => err);
|
||||
}
|
||||
|
||||
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,29 +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";
|
||||
|
||||
export const loadingInterceptor: HttpInterceptorFn = (
|
||||
req: HttpRequest<unknown>,
|
||||
next: HttpHandlerFn
|
||||
): Observable<HttpEvent<unknown>> => {
|
||||
|
||||
const loader = inject(LoaderService);
|
||||
|
||||
const isRefresh = req.url.includes('/auth/RefreshToken');
|
||||
if (!isRefresh) {
|
||||
loader.increase();
|
||||
}
|
||||
|
||||
return next(req).pipe(
|
||||
|
||||
// catchError(err => {
|
||||
// if (!isRefresh) loader.decrease();
|
||||
// throw err;
|
||||
// }),
|
||||
|
||||
finalize(() => {
|
||||
if (!isRefresh) loader.decrease();
|
||||
})
|
||||
);
|
||||
};
|
||||
@ -1,32 +0,0 @@
|
||||
@if(!loggedIn()){
|
||||
<router-outlet></router-outlet>
|
||||
} @else {
|
||||
<main>
|
||||
<app-contact></app-contact>
|
||||
<div class="main-content">
|
||||
|
||||
<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,21 +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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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,24 +0,0 @@
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LoaderService {
|
||||
|
||||
private _requestCount = signal(0);
|
||||
|
||||
// computed loader state
|
||||
isLoading = computed(() => this._requestCount() > 0);
|
||||
|
||||
increase() {
|
||||
this._requestCount.update(c => c + 1);
|
||||
}
|
||||
|
||||
decrease() {
|
||||
this._requestCount.update(c => Math.max(0, c - 1));
|
||||
}
|
||||
|
||||
reset() {
|
||||
this._requestCount.set(0);
|
||||
}
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
<div class="confirm-dialog" role="alertdialog">
|
||||
<div class="confirm-header">
|
||||
<i class="fa-solid fa-triangle-exclamation warn-icon"></i>
|
||||
<h3>{{ title }}</h3>
|
||||
</div>
|
||||
|
||||
<p class="confirm-message">{{ message }}</p>
|
||||
|
||||
<div class="confirm-actions">
|
||||
<button class="btn btn-cancel" (click)="onCancel()">{{ cancelLabel }}</button>
|
||||
<button class="btn" [class.btn-warn]="isWarn" [class.btn-primary]="!isWarn" (click)="onConfirm()">
|
||||
{{ confirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,86 +0,0 @@
|
||||
.confirm-dialog {
|
||||
padding: 24px;
|
||||
background: var(--eerie-black-2, #1e1e1e);
|
||||
border: 1px solid var(--jet, #383838);
|
||||
border-radius: 14px;
|
||||
color: var(--white-1, #fff);
|
||||
min-width: 320px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.confirm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
color: var(--white-1, #fff);
|
||||
}
|
||||
}
|
||||
|
||||
.warn-icon {
|
||||
color: #ff6b6b;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.confirm-message {
|
||||
margin: 0 0 20px;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.5;
|
||||
color: var(--white-2, #ddd);
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 18px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.1s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--light-gray-70, #aaa);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--white-1, #fff);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-warn {
|
||||
background: rgba(255, 70, 70, 0.15);
|
||||
color: #ff6b6b;
|
||||
border: 1px solid rgba(255, 70, 70, 0.25);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 70, 70, 0.25);
|
||||
color: #ff4444;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: rgba(227, 179, 65, 0.15);
|
||||
color: var(--orange-yellow-crayola, #e3b341);
|
||||
border: 1px solid rgba(227, 179, 65, 0.25);
|
||||
|
||||
&:hover {
|
||||
background: rgba(227, 179, 65, 0.25);
|
||||
}
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
|
||||
export interface ConfirmDialogData {
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
confirmColor?: 'warn' | 'primary' | 'accent';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirm-dialog',
|
||||
standalone: true,
|
||||
imports: [MatDialogModule],
|
||||
templateUrl: './confirm-dialog.html',
|
||||
styleUrls: ['./confirm-dialog.scss']
|
||||
})
|
||||
export class ConfirmDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<ConfirmDialogComponent>);
|
||||
data: ConfirmDialogData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
get title(): string {
|
||||
return this.data.title ?? 'Confirm';
|
||||
}
|
||||
|
||||
get message(): string {
|
||||
return this.data.message;
|
||||
}
|
||||
|
||||
get confirmLabel(): string {
|
||||
return this.data.confirmLabel ?? 'Delete';
|
||||
}
|
||||
|
||||
get cancelLabel(): string {
|
||||
return this.data.cancelLabel ?? 'Cancel';
|
||||
}
|
||||
|
||||
get isWarn(): boolean {
|
||||
return (this.data.confirmColor ?? 'warn') === 'warn';
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
|
||||
onConfirm(): void {
|
||||
this.dialogRef.close(true);
|
||||
}
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
export interface DynamicField {
|
||||
name: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
type: 'text' | 'textarea' | 'number' | 'date' | 'select' | 'file' | 'array' | 'hidden' | 'year';
|
||||
required?: boolean;
|
||||
options?: { label: string; value: unknown }[];
|
||||
itemConfig?: DynamicField[];
|
||||
yearRange?: { start?: number; end?: number; allowPresent?: boolean; valueType?: 'string' | 'number' };
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import { DynamicField } from "./dynamic-field";
|
||||
|
||||
export interface DynamicFormConfig {
|
||||
title: string;
|
||||
submitLabel: string;
|
||||
api?: {
|
||||
save: string; // POST or PUT endpoint
|
||||
method: 'POST' | 'PUT';
|
||||
bodyKey?: string; // extract this key from form value before sending (e.g. 'academics' sends the array directly)
|
||||
};
|
||||
fields: DynamicField[];
|
||||
}
|
||||
@ -1,141 +0,0 @@
|
||||
<form [formGroup]="form">
|
||||
|
||||
@for (f of config.fields; track f) {
|
||||
|
||||
<ng-container *ngIf="f.type !== 'array' && f.type !== 'hidden'">
|
||||
@if (f.type === 'text') {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>{{ f.label }}</mat-label>
|
||||
<input matInput [id]="f.name" [formControlName]="f.name" />
|
||||
<mat-hint *ngIf="f.placeholder">{{ f.placeholder }}</mat-hint>
|
||||
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
|
||||
This field is required.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (f.type === 'textarea') {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>{{ f.label }}</mat-label>
|
||||
<textarea matInput [id]="f.name" cdkTextareaAutosize cdkAutosizeMinRows="3" cdkAutosizeMaxRows="6" [formControlName]="f.name"></textarea>
|
||||
<mat-hint *ngIf="f.placeholder">{{ f.placeholder }}</mat-hint>
|
||||
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
|
||||
This field is required.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (f.type === 'number') {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>{{ f.label }}</mat-label>
|
||||
<input matInput [id]="f.name" type="number" [formControlName]="f.name" />
|
||||
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
|
||||
Please enter a valid number.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (f.type === 'select') {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>{{ f.label }}</mat-label>
|
||||
<mat-select [id]="f.name" [formControlName]="f.name">
|
||||
@for (opt of f.options; track opt) {
|
||||
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
|
||||
Please select a value.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (f.type === 'year') {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>{{ f.label }}</mat-label>
|
||||
<mat-select [id]="f.name" [formControlName]="f.name" [compareWith]="compareYearValues">
|
||||
@if (f.yearRange?.allowPresent) {
|
||||
<mat-option value="Present">Present</mat-option>
|
||||
}
|
||||
@for (yr of getYearOptions(f); track yr) {
|
||||
<mat-option [value]="yr">{{ yr }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
|
||||
Please select a year.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (f.type === 'date') {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>{{ f.label }}</mat-label>
|
||||
<input matInput [matDatepicker]="picker" [id]="f.name" [formControlName]="f.name" [placeholder]="f.placeholder || ''" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #picker></mat-datepicker>
|
||||
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
|
||||
Please select a date.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (f.type === 'file') {
|
||||
<div>
|
||||
<label [for]="f.name">{{ f.label }}</label>
|
||||
<input [id]="f.name" type="file" (change)="onFileChange($event, f.name)" />
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="f.type === 'array'">
|
||||
<div>
|
||||
<h3>{{ f.label }}</h3>
|
||||
<div [formArrayName]="f.name">
|
||||
@for (item of getArrayControls(f.name); track item; let i = $index) {
|
||||
<div [formGroupName]="i" class="array-item">
|
||||
<div class="array-item-fields">
|
||||
@for (sub of f.itemConfig; track sub) {
|
||||
@if (sub.type !== 'hidden') {
|
||||
@if (sub.type === 'date') {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>{{ sub.label }}</mat-label>
|
||||
<input matInput [matDatepicker]="subPicker" [id]="sub.name + '_' + i" [formControlName]="sub.name" [placeholder]="sub.placeholder || ''" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="subPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #subPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
} @else if (sub.type === 'year') {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>{{ sub.label }}</mat-label>
|
||||
<mat-select [id]="sub.name + '_' + i" [formControlName]="sub.name" [compareWith]="compareYearValues">
|
||||
@if (sub.yearRange?.allowPresent) {
|
||||
<mat-option value="Present">Present</mat-option>
|
||||
}
|
||||
@for (yr of getYearOptions(sub); track yr) {
|
||||
<mat-option [value]="yr">{{ yr }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
} @else {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>{{ sub.label }}</mat-label>
|
||||
<input matInput [id]="sub.name + '_' + i" [formControlName]="sub.name" [placeholder]="sub.placeholder || ''" />
|
||||
</mat-form-field>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="array-item-actions">
|
||||
<button type="button" class="remove-link" (click)="removeArrayItem(f, i)">
|
||||
<i class="fa-solid fa-trash-can"></i> Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="add-btn" (click)="addArrayItem(f)">
|
||||
<i class="fa-solid fa-plus"></i> Add Item
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
</form>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user