develop #8
22
angular.json
22
angular.json
@ -2,7 +2,10 @@
|
|||||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"cli": {
|
"cli": {
|
||||||
"packageManager": "npm"
|
"packageManager": "npm",
|
||||||
|
"schematicCollections": [
|
||||||
|
"angular-eslint"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"newProjectRoot": "projects",
|
"newProjectRoot": "projects",
|
||||||
"projects": {
|
"projects": {
|
||||||
@ -20,6 +23,7 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular/build:application",
|
"builder": "@angular/build:application",
|
||||||
"options": {
|
"options": {
|
||||||
|
"outputPath": "dist/portfolio-admin",
|
||||||
"browser": "src/main.ts",
|
"browser": "src/main.ts",
|
||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
"inlineStyleLanguage": "scss",
|
"inlineStyleLanguage": "scss",
|
||||||
@ -31,12 +35,7 @@
|
|||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
],
|
]
|
||||||
"server": "src/main.server.ts",
|
|
||||||
"outputMode": "server",
|
|
||||||
"ssr": {
|
|
||||||
"entry": "src/server.ts"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@ -92,6 +91,15 @@
|
|||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"builder": "@angular-eslint/builder:lint",
|
||||||
|
"options": {
|
||||||
|
"lintFilePatterns": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.html"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
eslint.config.js
Normal file
43
eslint.config.js
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
// @ts-check
|
||||||
|
const eslint = require("@eslint/js");
|
||||||
|
const tseslint = require("typescript-eslint");
|
||||||
|
const angular = require("angular-eslint");
|
||||||
|
|
||||||
|
module.exports = tseslint.config(
|
||||||
|
{
|
||||||
|
files: ["**/*.ts"],
|
||||||
|
extends: [
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
...tseslint.configs.stylistic,
|
||||||
|
...angular.configs.tsRecommended,
|
||||||
|
],
|
||||||
|
processor: angular.processInlineTemplates,
|
||||||
|
rules: {
|
||||||
|
"@angular-eslint/directive-selector": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
type: "attribute",
|
||||||
|
prefix: "app",
|
||||||
|
style: "camelCase",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"@angular-eslint/component-selector": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
type: "element",
|
||||||
|
prefix: "app",
|
||||||
|
style: "kebab-case",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.html"],
|
||||||
|
extends: [
|
||||||
|
...angular.configs.templateRecommended,
|
||||||
|
...angular.configs.templateAccessibility,
|
||||||
|
],
|
||||||
|
rules: {},
|
||||||
|
}
|
||||||
|
);
|
||||||
2195
package-lock.json
generated
2195
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@ -7,7 +7,8 @@
|
|||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
"serve:ssr:portfolio-admin": "node dist/portfolio-admin/server/server.mjs"
|
"serve:ssr:portfolio-admin": "node dist/portfolio-admin/server/server.mjs",
|
||||||
|
"lint": "ng lint"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
@ -23,6 +24,7 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@angular/animations": "20.3.4",
|
||||||
"@angular/cdk": "^20.2.5",
|
"@angular/cdk": "^20.2.5",
|
||||||
"@angular/common": "^20.3.0",
|
"@angular/common": "^20.3.0",
|
||||||
"@angular/compiler": "^20.3.0",
|
"@angular/compiler": "^20.3.0",
|
||||||
@ -33,6 +35,7 @@
|
|||||||
"@angular/platform-server": "^20.3.0",
|
"@angular/platform-server": "^20.3.0",
|
||||||
"@angular/router": "^20.3.0",
|
"@angular/router": "^20.3.0",
|
||||||
"@angular/ssr": "^20.3.2",
|
"@angular/ssr": "^20.3.2",
|
||||||
|
"@datorama/akita": "^8.0.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
@ -44,12 +47,15 @@
|
|||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
"@types/node": "^20.17.19",
|
"@types/node": "^20.17.19",
|
||||||
|
"angular-eslint": "20.6.0",
|
||||||
|
"eslint": "^9.39.0",
|
||||||
"jasmine-core": "~5.9.0",
|
"jasmine-core": "~5.9.0",
|
||||||
"karma": "~6.4.0",
|
"karma": "~6.4.0",
|
||||||
"karma-chrome-launcher": "~3.2.0",
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
"karma-coverage": "~2.2.0",
|
"karma-coverage": "~2.2.0",
|
||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.1.0",
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.2",
|
||||||
|
"typescript-eslint": "8.46.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
0
public/assets/.gitkeep
Normal file
0
public/assets/.gitkeep
Normal file
12
public/assets/css/all.min.css
vendored
Normal file
12
public/assets/css/all.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
public/assets/images/my-avatar.png
Normal file
BIN
public/assets/images/my-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
BIN
public/assets/images/my-pic.png
Normal file
BIN
public/assets/images/my-pic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
159
public/assets/js/script.js
Normal file
159
public/assets/js/script.js
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// element toggle function
|
||||||
|
const elementToggleFunc = function (elem) { elem.classList.toggle("active"); }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// sidebar variables
|
||||||
|
const sidebar = document.querySelector("[data-sidebar]");
|
||||||
|
const sidebarBtn = document.querySelector("[data-sidebar-btn]");
|
||||||
|
|
||||||
|
// sidebar toggle functionality for mobile
|
||||||
|
sidebarBtn.addEventListener("click", function () { elementToggleFunc(sidebar); });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// testimonials variables
|
||||||
|
const testimonialsItem = document.querySelectorAll("[data-testimonials-item]");
|
||||||
|
const modalContainer = document.querySelector("[data-modal-container]");
|
||||||
|
const modalCloseBtn = document.querySelector("[data-modal-close-btn]");
|
||||||
|
const overlay = document.querySelector("[data-overlay]");
|
||||||
|
|
||||||
|
// modal variable
|
||||||
|
const modalImg = document.querySelector("[data-modal-img]");
|
||||||
|
const modalTitle = document.querySelector("[data-modal-title]");
|
||||||
|
const modalText = document.querySelector("[data-modal-text]");
|
||||||
|
|
||||||
|
// modal toggle function
|
||||||
|
const testimonialsModalFunc = function () {
|
||||||
|
modalContainer.classList.toggle("active");
|
||||||
|
overlay.classList.toggle("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
// add click event to all modal items
|
||||||
|
for (let i = 0; i < testimonialsItem.length; i++) {
|
||||||
|
|
||||||
|
testimonialsItem[i].addEventListener("click", function () {
|
||||||
|
|
||||||
|
modalImg.src = this.querySelector("[data-testimonials-avatar]").src;
|
||||||
|
modalImg.alt = this.querySelector("[data-testimonials-avatar]").alt;
|
||||||
|
modalTitle.innerHTML = this.querySelector("[data-testimonials-title]").innerHTML;
|
||||||
|
modalText.innerHTML = this.querySelector("[data-testimonials-text]").innerHTML;
|
||||||
|
|
||||||
|
testimonialsModalFunc();
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// add click event to modal close button
|
||||||
|
modalCloseBtn.addEventListener("click", testimonialsModalFunc);
|
||||||
|
overlay.addEventListener("click", testimonialsModalFunc);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// custom select variables
|
||||||
|
const select = document.querySelector("[data-select]");
|
||||||
|
const selectItems = document.querySelectorAll("[data-select-item]");
|
||||||
|
const selectValue = document.querySelector("[data-selecct-value]");
|
||||||
|
const filterBtn = document.querySelectorAll("[data-filter-btn]");
|
||||||
|
|
||||||
|
select.addEventListener("click", function () { elementToggleFunc(this); });
|
||||||
|
|
||||||
|
// add event in all select items
|
||||||
|
for (let i = 0; i < selectItems.length; i++) {
|
||||||
|
selectItems[i].addEventListener("click", function () {
|
||||||
|
|
||||||
|
let selectedValue = this.innerText.toLowerCase();
|
||||||
|
selectValue.innerText = this.innerText;
|
||||||
|
elementToggleFunc(select);
|
||||||
|
filterFunc(selectedValue);
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// filter variables
|
||||||
|
const filterItems = document.querySelectorAll("[data-filter-item]");
|
||||||
|
|
||||||
|
const filterFunc = function (selectedValue) {
|
||||||
|
|
||||||
|
for (let i = 0; i < filterItems.length; i++) {
|
||||||
|
|
||||||
|
if (selectedValue === "all") {
|
||||||
|
filterItems[i].classList.add("active");
|
||||||
|
} else if (selectedValue === filterItems[i].dataset.category) {
|
||||||
|
filterItems[i].classList.add("active");
|
||||||
|
} else {
|
||||||
|
filterItems[i].classList.remove("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// add event in all filter button items for large screen
|
||||||
|
let lastClickedBtn = filterBtn[0];
|
||||||
|
|
||||||
|
for (let i = 0; i < filterBtn.length; i++) {
|
||||||
|
|
||||||
|
filterBtn[i].addEventListener("click", function () {
|
||||||
|
|
||||||
|
let selectedValue = this.innerText.toLowerCase();
|
||||||
|
selectValue.innerText = this.innerText;
|
||||||
|
filterFunc(selectedValue);
|
||||||
|
|
||||||
|
lastClickedBtn.classList.remove("active");
|
||||||
|
this.classList.add("active");
|
||||||
|
lastClickedBtn = this;
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// contact form variables
|
||||||
|
const form = document.querySelector("[data-form]");
|
||||||
|
const formInputs = document.querySelectorAll("[data-form-input]");
|
||||||
|
const formBtn = document.querySelector("[data-form-btn]");
|
||||||
|
|
||||||
|
// add event to all form input field
|
||||||
|
for (let i = 0; i < formInputs.length; i++) {
|
||||||
|
formInputs[i].addEventListener("input", function () {
|
||||||
|
|
||||||
|
// check form validation
|
||||||
|
if (form.checkValidity()) {
|
||||||
|
formBtn.removeAttribute("disabled");
|
||||||
|
} else {
|
||||||
|
formBtn.setAttribute("disabled", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// page navigation variables
|
||||||
|
const navigationLinks = document.querySelectorAll("[data-nav-link]");
|
||||||
|
const pages = document.querySelectorAll("[data-page]");
|
||||||
|
|
||||||
|
// add event to all nav link
|
||||||
|
for (let i = 0; i < navigationLinks.length; i++) {
|
||||||
|
navigationLinks[i].addEventListener("click", function () {
|
||||||
|
|
||||||
|
for (let i = 0; i < pages.length; i++) {
|
||||||
|
if (this.innerHTML.toLowerCase() === pages[i].dataset.page) {
|
||||||
|
pages[i].classList.add("active");
|
||||||
|
navigationLinks[i].classList.add("active");
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
} else {
|
||||||
|
pages[i].classList.remove("active");
|
||||||
|
navigationLinks[i].classList.remove("active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
BIN
public/assets/webfonts/fa-brands-400.ttf
Normal file
BIN
public/assets/webfonts/fa-brands-400.ttf
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-brands-400.woff2
Normal file
BIN
public/assets/webfonts/fa-brands-400.woff2
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-duotone-900.ttf
Normal file
BIN
public/assets/webfonts/fa-duotone-900.ttf
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-duotone-900.woff2
Normal file
BIN
public/assets/webfonts/fa-duotone-900.woff2
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-light-300.ttf
Normal file
BIN
public/assets/webfonts/fa-light-300.ttf
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-light-300.woff2
Normal file
BIN
public/assets/webfonts/fa-light-300.woff2
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-regular-400.ttf
Normal file
BIN
public/assets/webfonts/fa-regular-400.ttf
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-regular-400.woff2
Normal file
BIN
public/assets/webfonts/fa-regular-400.woff2
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-sharp-light-300.ttf
Normal file
BIN
public/assets/webfonts/fa-sharp-light-300.ttf
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-sharp-light-300.woff2
Normal file
BIN
public/assets/webfonts/fa-sharp-light-300.woff2
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-sharp-regular-400.ttf
Normal file
BIN
public/assets/webfonts/fa-sharp-regular-400.ttf
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-sharp-regular-400.woff2
Normal file
BIN
public/assets/webfonts/fa-sharp-regular-400.woff2
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-sharp-solid-900.ttf
Normal file
BIN
public/assets/webfonts/fa-sharp-solid-900.ttf
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-sharp-solid-900.woff2
Normal file
BIN
public/assets/webfonts/fa-sharp-solid-900.woff2
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-sharp-thin-100.ttf
Normal file
BIN
public/assets/webfonts/fa-sharp-thin-100.ttf
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-sharp-thin-100.woff2
Normal file
BIN
public/assets/webfonts/fa-sharp-thin-100.woff2
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-solid-900.ttf
Normal file
BIN
public/assets/webfonts/fa-solid-900.ttf
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-solid-900.woff2
Normal file
BIN
public/assets/webfonts/fa-solid-900.woff2
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-thin-100.ttf
Normal file
BIN
public/assets/webfonts/fa-thin-100.ttf
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-thin-100.woff2
Normal file
BIN
public/assets/webfonts/fa-thin-100.woff2
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-v4compatibility.ttf
Normal file
BIN
public/assets/webfonts/fa-v4compatibility.ttf
Normal file
Binary file not shown.
BIN
public/assets/webfonts/fa-v4compatibility.woff2
Normal file
BIN
public/assets/webfonts/fa-v4compatibility.woff2
Normal file
Binary file not shown.
17
public/favicon.svg
Normal file
17
public/favicon.svg
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 805 B |
41
src/app/admin/about/about.html
Normal file
41
src/app/admin/about/about.html
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
@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>
|
||||||
|
}
|
||||||
7
src/app/admin/about/about.model.ts
Normal file
7
src/app/admin/about/about.model.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { IHobby } from "../models/hobby.model";
|
||||||
|
|
||||||
|
export interface IAbout{
|
||||||
|
about: string;
|
||||||
|
title: string;
|
||||||
|
hobbies: IHobby[];
|
||||||
|
}
|
||||||
37
src/app/admin/about/about.scss
Normal file
37
src/app/admin/about/about.scss
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
23
src/app/admin/about/about.spec.ts
Normal file
23
src/app/admin/about/about.spec.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { About } from './about';
|
||||||
|
|
||||||
|
describe('About', () => {
|
||||||
|
let component: About;
|
||||||
|
let fixture: ComponentFixture<About>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [About]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(About);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
89
src/app/admin/about/about.ts
Normal file
89
src/app/admin/about/about.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/app/admin/contact/contact.html
Normal file
132
src/app/admin/contact/contact.html
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
@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>
|
||||||
|
}
|
||||||
8
src/app/admin/contact/contact.model.ts
Normal file
8
src/app/admin/contact/contact.model.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { ICandidate } from "../models/candidate.model";
|
||||||
|
import { ISocialLinks } from "../models/social-links.model";
|
||||||
|
|
||||||
|
export interface IContactModel {
|
||||||
|
title: string;
|
||||||
|
candidate?: ICandidate;
|
||||||
|
socialLinks?: ISocialLinks;
|
||||||
|
}
|
||||||
86
src/app/admin/contact/contact.scss
Normal file
86
src/app/admin/contact/contact.scss
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/app/admin/contact/contact.spec.ts
Normal file
23
src/app/admin/contact/contact.spec.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { Contact } from './contact';
|
||||||
|
|
||||||
|
describe('Contact', () => {
|
||||||
|
let component: Contact;
|
||||||
|
let fixture: ComponentFixture<Contact>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Contact]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Contact);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
121
src/app/admin/contact/contact.ts
Normal file
121
src/app/admin/contact/contact.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/app/admin/models/academic.model.ts
Normal file
9
src/app/admin/models/academic.model.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export interface IAcademic{
|
||||||
|
academicId: number;
|
||||||
|
institution: string;
|
||||||
|
startYear: number;
|
||||||
|
endYear: number;
|
||||||
|
degree: string;
|
||||||
|
period: string;
|
||||||
|
degreeSpecialization: string;
|
||||||
|
}
|
||||||
10
src/app/admin/models/candidate.model.ts
Normal file
10
src/app/admin/models/candidate.model.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export interface ICandidate{
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
dob: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
address: string;
|
||||||
|
avatar: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
8
src/app/admin/models/certification.model.ts
Normal file
8
src/app/admin/models/certification.model.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export interface ICertification {
|
||||||
|
certificationId: number;
|
||||||
|
certificationName: string;
|
||||||
|
issuingOrganization: string;
|
||||||
|
certificationLink: string;
|
||||||
|
issueDate: string;
|
||||||
|
expiryDate?: string;
|
||||||
|
}
|
||||||
21
src/app/admin/models/cv.model.ts
Normal file
21
src/app/admin/models/cv.model.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { IAcademic } from "./academic.model";
|
||||||
|
import { ICandidate } from "./candidate.model";
|
||||||
|
import { IExperience } from "./experience.model";
|
||||||
|
import { IHobby } from "./hobby.model";
|
||||||
|
import { IProject } from "./project.model";
|
||||||
|
import { ISkill } from "./skill.model";
|
||||||
|
import { ISocialLinks } from "./social-links.model";
|
||||||
|
|
||||||
|
export interface ICv{
|
||||||
|
resumeId: number;
|
||||||
|
title: string;
|
||||||
|
about: string;
|
||||||
|
candidate: ICandidate;
|
||||||
|
socialLinks: ISocialLinks;
|
||||||
|
academics: IAcademic[];
|
||||||
|
skills: ISkill[];
|
||||||
|
experiences: IExperience[];
|
||||||
|
hobbies: IHobby[];
|
||||||
|
projects: IProject[];
|
||||||
|
projectsCategories: string[];
|
||||||
|
}
|
||||||
5
src/app/admin/models/experience-details.model.ts
Normal file
5
src/app/admin/models/experience-details.model.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export interface IExperienceDetails{
|
||||||
|
id: number;
|
||||||
|
details: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
15
src/app/admin/models/experience.model.ts
Normal file
15
src/app/admin/models/experience.model.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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[];
|
||||||
|
}
|
||||||
6
src/app/admin/models/hobby.model.ts
Normal file
6
src/app/admin/models/hobby.model.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export interface IHobby{
|
||||||
|
hobbyId: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
17
src/app/admin/models/project.model.ts
Normal file
17
src/app/admin/models/project.model.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
6
src/app/admin/models/skill.model.ts
Normal file
6
src/app/admin/models/skill.model.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export interface ISkill{
|
||||||
|
skillId: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
proficiencyLevel: number;
|
||||||
|
}
|
||||||
6
src/app/admin/models/social-links.model.ts
Normal file
6
src/app/admin/models/social-links.model.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export interface ISocialLinks{
|
||||||
|
id: number;
|
||||||
|
gitHub: string;
|
||||||
|
linkedin: string;
|
||||||
|
blogUrl: string;
|
||||||
|
}
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/app/admin/projects/projects.html
Normal file
89
src/app/admin/projects/projects.html
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<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>
|
||||||
6
src/app/admin/projects/projects.model.ts
Normal file
6
src/app/admin/projects/projects.model.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { IProject } from "../models/project.model";
|
||||||
|
|
||||||
|
export interface IProjects{
|
||||||
|
projects: IProject[];
|
||||||
|
projectsCategories: string[];
|
||||||
|
}
|
||||||
97
src/app/admin/projects/projects.scss
Normal file
97
src/app/admin/projects/projects.scss
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
23
src/app/admin/projects/projects.spec.ts
Normal file
23
src/app/admin/projects/projects.spec.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { Projects } from './projects';
|
||||||
|
|
||||||
|
describe('Projects', () => {
|
||||||
|
let component: Projects;
|
||||||
|
let fixture: ComponentFixture<Projects>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Projects]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Projects);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
223
src/app/admin/projects/projects.ts
Normal file
223
src/app/admin/projects/projects.ts
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
153
src/app/admin/resume/resume.html
Normal file
153
src/app/admin/resume/resume.html
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
@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>
|
||||||
|
}
|
||||||
11
src/app/admin/resume/resume.model.ts
Normal file
11
src/app/admin/resume/resume.model.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
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[];
|
||||||
|
}
|
||||||
41
src/app/admin/resume/resume.scss
Normal file
41
src/app/admin/resume/resume.scss
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/app/admin/resume/resume.spec.ts
Normal file
23
src/app/admin/resume/resume.spec.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { Resume } from './resume';
|
||||||
|
|
||||||
|
describe('Resume', () => {
|
||||||
|
let component: Resume;
|
||||||
|
let fixture: ComponentFixture<Resume>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Resume]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Resume);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
216
src/app/admin/resume/resume.ts
Normal file
216
src/app/admin/resume/resume.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/app/admin/services/admin.service.spec.ts
Normal file
16
src/app/admin/services/admin.service.spec.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AdminService } from './admin.service';
|
||||||
|
|
||||||
|
describe('AdminService', () => {
|
||||||
|
let service: AdminService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
service = TestBed.inject(AdminService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
50
src/app/admin/services/admin.service.ts
Normal file
50
src/app/admin/services/admin.service.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/app/admin/state/admin-state.service.ts
Normal file
140
src/app/admin/state/admin-state.service.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/app/admin/state/admin.query.ts
Normal file
48
src/app/admin/state/admin.query.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/app/admin/state/admin.store.ts
Normal file
48
src/app/admin/state/admin.store.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
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 }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +0,0 @@
|
|||||||
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 { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
|
||||||
|
import { provideAnimations } from '@angular/platform-browser/animations';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
import { provideHttpClient, withFetch, withInterceptors} from '@angular/common/http';
|
||||||
import { provideHttpClient, withFetch, withInterceptorsFromDi } from '@angular/common/http';
|
import { loadingInterceptor } from './interceptors/loading-interceptor';
|
||||||
import { httpInterceptorProviders } from './interceptors';
|
import { AuthInterceptor } from './interceptors/auth-interceptor';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideZonelessChangeDetection(),
|
provideZonelessChangeDetection(),
|
||||||
provideHttpClient(withInterceptorsFromDi(), withFetch()),
|
provideAnimations(),
|
||||||
httpInterceptorProviders,
|
provideHttpClient(withInterceptors([loadingInterceptor, AuthInterceptor]), withFetch()),
|
||||||
provideRouter(routes), provideClientHydration(withEventReplay())
|
provideRouter(routes)
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
<main>
|
<div>
|
||||||
<router-outlet></router-outlet>
|
@if(loading()){
|
||||||
</main>
|
<app-spinner></app-spinner>
|
||||||
|
}
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</div>
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import { RenderMode, ServerRoute } from '@angular/ssr';
|
|
||||||
|
|
||||||
export const serverRoutes: ServerRoute[] = [
|
|
||||||
{
|
|
||||||
path: '**',
|
|
||||||
renderMode: RenderMode.Prerender
|
|
||||||
}
|
|
||||||
];
|
|
||||||
@ -1,6 +1,53 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { OtpComponent } from './auth/otp/otp.component';
|
import { OtpComponent } from './auth/otp/otp.component';
|
||||||
|
import { authGuard } from './guards/auth-guard';
|
||||||
|
import { AdminLayout } from './layout/admin-layout/admin-layout';
|
||||||
|
import { About } from './admin/about/about';
|
||||||
|
import { Resume } from './admin/resume/resume';
|
||||||
|
import { Projects } from './admin/projects/projects';
|
||||||
|
|
||||||
|
const enum AdminRouteTitles {
|
||||||
|
Login = 'Login',
|
||||||
|
About = 'About',
|
||||||
|
Resume = 'Resume',
|
||||||
|
Projects = 'Projects',
|
||||||
|
}
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '**', pathMatch: 'full', component: OtpComponent }
|
{
|
||||||
|
path: '',
|
||||||
|
redirectTo: '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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,17 @@
|
|||||||
import { Component, signal } from '@angular/core';
|
import { Component, CUSTOM_ELEMENTS_SCHEMA, inject, signal } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { SpinnerComponent } from './spinner/spinner.component';
|
||||||
|
import { LoaderService } from './services/loader.service';
|
||||||
|
import { RouterModule } from "@angular/router";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet],
|
imports: [SpinnerComponent, RouterModule],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrls: ['./app.scss']
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
|
loader = inject(LoaderService);
|
||||||
protected readonly title = signal('portfolio-admin');
|
protected readonly title = signal('portfolio-admin');
|
||||||
|
protected readonly loading = this.loader.isLoading;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,13 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { BrowserModule } from '@angular/platform-browser';
|
import { BrowserModule } from '@angular/platform-browser';
|
||||||
import { OtpComponent } from './otp/otp.component';
|
|
||||||
import { ReactiveFormsModule } from '@angular/forms';
|
|
||||||
import { AuthInterceptor } from '../interceptors/auth-interceptor';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
|
||||||
OtpComponent
|
|
||||||
],
|
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
ReactiveFormsModule
|
|
||||||
],
|
|
||||||
providers:[
|
|
||||||
AuthInterceptor
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class AuthModule { }
|
export class AuthModule { }
|
||||||
|
|||||||
@ -1,35 +1,128 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
|
||||||
import { Observable, of } from 'rxjs';
|
import { BehaviorSubject, map, Observable } from 'rxjs';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
|
interface ValidateOtpResponse {
|
||||||
|
accessToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RefreshTokenResponse {
|
||||||
|
accessToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly baseUrl = (environment.apiUrl ?? '').replace(/\/+$/, '');
|
private readonly baseUrl = (environment.apiUrl ?? '').replace(/\/+$/, '');
|
||||||
|
private accessToken: string | null = null;
|
||||||
|
private platformId = inject(PLATFORM_ID);
|
||||||
|
public accessTokenSub = new BehaviorSubject<string | null>(null);
|
||||||
|
http = inject(HttpClient);
|
||||||
|
router = inject(Router);
|
||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
private readonly storageKey = 'accessToken';
|
||||||
|
tokenReady$ = new BehaviorSubject<boolean | null>(null);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.accessToken = this.safeGetToken();
|
||||||
|
}
|
||||||
|
|
||||||
private api(path: string) {
|
private api(path: string) {
|
||||||
return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
|
return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendOtp(email: string): Observable<any> {
|
// Call on app start, or from guard
|
||||||
|
async ensureTokenReady(): Promise<void>{
|
||||||
|
if(this.tokenReady$.value) return;
|
||||||
|
|
||||||
|
const stored = this.safeGetToken();
|
||||||
|
|
||||||
|
// try to restore from storage
|
||||||
|
if(stored){
|
||||||
|
this.accessTokenSub.next(stored);
|
||||||
|
this.tokenReady$.next(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentToken(): string | null {
|
||||||
|
return this.safeGetToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
safeSetToken(token: string) {
|
||||||
|
this.accessToken = token;
|
||||||
|
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
localStorage.setItem(this.storageKey, token);
|
||||||
|
this.accessTokenSub.next(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private safeGetToken(): string | null {
|
||||||
|
try {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
const token = localStorage.getItem(this.storageKey);
|
||||||
|
this.accessTokenSub.next(token);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to read from localStorage:', e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private safeRemoveToken() {
|
||||||
|
this.accessToken = null;
|
||||||
|
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
localStorage.removeItem(this.storageKey);
|
||||||
|
this.accessTokenSub.next(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendOtp(email: string): Observable<unknown> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('email', email);
|
formData.append('email', email);
|
||||||
return this.http.post(this.api('/api/v1/auth/GenerateOtp'), formData);
|
return this.http.post(this.api('/api/v1/auth/GenerateOtp'), formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
verifyOtp(userId: string, otpCode: string): Observable<any> {
|
verifyOtp(userId: string, otpCode: string): Observable<void> {
|
||||||
const body = {
|
const body = {
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
OtpCode: otpCode
|
OtpCode: otpCode
|
||||||
};
|
};
|
||||||
return this.http.post(this.api('/api/v1/auth/ValidateOtp'), body);
|
return this.http.post<ValidateOtpResponse>(this.api('/api/v1/auth/ValidateOtp'), body).pipe(map((response: ValidateOtpResponse) => {
|
||||||
|
if (response && response.accessToken) {
|
||||||
|
this.accessToken = response.accessToken;
|
||||||
|
this.safeSetToken(response.accessToken);
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
getApiKey(): string{
|
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 {
|
||||||
return environment.apiKey;
|
return environment.apiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isLoggedIn(): boolean {
|
||||||
|
return this.safeGetToken() != null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,40 +1,55 @@
|
|||||||
<div class="otp-container">
|
<div class="verify-wrapper">
|
||||||
<h2>🔐 Email Verification</h2>
|
<div class="verify-card">
|
||||||
|
|
||||||
|
@if(!isOtpSent()){
|
||||||
|
<h2 class="title">🔐 Login</h2>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if(isOtpSent() && !isVerified()){
|
||||||
|
<h2 class="title">🔐 OTP Verification</h2>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Step 1: Enter Email -->
|
<!-- Step 1: Enter Email -->
|
||||||
@if (!isOtpSent()) {
|
@if (!isOtpSent()) {
|
||||||
<form [formGroup]="emailForm" (ngSubmit)="sendOtp()">
|
<form [formGroup]="emailForm" (ngSubmit)="sendOtp()" class="form-section">
|
||||||
<label>Email Address</label>
|
<label for="email">Email Address</label>
|
||||||
<input type="email" formControlName="email" placeholder="Enter your email" />
|
<input id="email" type="email" formControlName="email" placeholder="Enter your email" />
|
||||||
<button type="submit" [disabled]="emailForm.invalid">Send OTP</button>
|
<button type="submit" [disabled]="emailForm.invalid">Send OTP</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Step 2: Enter OTP -->
|
<!-- Step 2: OTP Input -->
|
||||||
@if (isOtpSent() && !isVerified()) {
|
@if (isOtpSent() && !isVerified()) {
|
||||||
<div>
|
<div class="otp-section">
|
||||||
<p>OTP sent to <b>{{ emailForm.value.email }}</b></p>
|
|
||||||
|
|
||||||
<form [formGroup]="otpForm" (ngSubmit)="verifyOtp()">
|
<form [formGroup]="otpForm" (ngSubmit)="verifyOtp()">
|
||||||
<label>Enter 6-digit OTP</label>
|
<label for="otp">Enter 6-digit OTP</label>
|
||||||
<input type="text" maxlength="6" formControlName="otp" placeholder="123456" />
|
<input id="otp" type="text" maxlength="6" formControlName="otp" placeholder="123456" />
|
||||||
<button type="submit" [disabled]="otpForm.invalid">Verify OTP</button>
|
<button type="submit" [disabled]="otpForm.invalid">
|
||||||
|
Verify OTP
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<button (click)="resendOtp()" [disabled]="countdown() > 0">
|
<button class="resend-btn" (click)="resendOtp()" [disabled]="countdown() > 0">
|
||||||
Resend OTP @if (countdown() > 0) {
|
Resend OTP
|
||||||
|
@if (countdown() > 0) {
|
||||||
<span>({{ countdown() }}s)</span>
|
<span>({{ countdown() }}s)</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Step 3: Success -->
|
@if (isError()) {
|
||||||
@if (isVerified()) {
|
<div class="error-banner">
|
||||||
<div>
|
<i class="fa-solid fa-circle-exclamation"></i>
|
||||||
<p class="success">✅ Your email has been verified successfully!</p>
|
<span>{{ message() }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<p class="message">{{ message() }}</p>
|
@if (!isError() && isOtpSent() && !isVerified()) {
|
||||||
|
<div class="success-banner">
|
||||||
|
<i class="fa-solid fa-circle-check"></i>
|
||||||
|
<span>{{ message() }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1,47 +1,193 @@
|
|||||||
.otp-container {
|
/* Background wrapper */
|
||||||
max-width: 400px;
|
.verify-wrapper {
|
||||||
margin: 60px auto;
|
width: 100%;
|
||||||
padding: 30px;
|
height: 100vh;
|
||||||
background: #fff;
|
background: #0e0e0e;
|
||||||
border-radius: 16px;
|
display: flex;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
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;
|
text-align: center;
|
||||||
|
color: #fff;
|
||||||
h2 {
|
font-size: 24px;
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px;
|
|
||||||
margin: 10px 0;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
color: green;
|
|
||||||
font-weight: 600;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
&::placeholder {
|
||||||
margin-top: 15px;
|
color: #777;
|
||||||
color: #555;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #555;
|
||||||
|
color: #aaa;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:disabled):hover {
|
||||||
|
background: #f2c85c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resend OTP button */
|
||||||
|
.resend-btn {
|
||||||
|
margin-top: 12px;
|
||||||
|
background: transparent !important;
|
||||||
|
color: #e3b341 !important;
|
||||||
|
border: 1px solid #e3b341;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: rgba(227, 179, 65, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
border-color: #555;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success message */
|
||||||
|
.success-msg {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #78ff8c;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error / general message */
|
||||||
|
.error-message {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #ff6b6b;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,23 +1,31 @@
|
|||||||
import { Component, signal } from '@angular/core';
|
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
import { AuthService } from '../auth.service';
|
import { AuthService } from '../auth.service';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-otp',
|
selector: 'app-otp',
|
||||||
standalone: false,
|
|
||||||
templateUrl: './otp.component.html',
|
templateUrl: './otp.component.html',
|
||||||
styleUrls: ['./otp.component.scss']
|
styleUrls: ['./otp.component.scss'],
|
||||||
|
imports: [ReactiveFormsModule, CommonModule]
|
||||||
})
|
})
|
||||||
export class OtpComponent {
|
export class OtpComponent implements OnInit {
|
||||||
emailForm: FormGroup;
|
emailForm: FormGroup;
|
||||||
otpForm: FormGroup;
|
otpForm: FormGroup;
|
||||||
isOtpSent = signal(false);
|
isOtpSent = signal(false);
|
||||||
isVerified = signal(false);
|
isVerified = signal(false);
|
||||||
|
isError = signal(false);
|
||||||
message = signal('');
|
message = signal('');
|
||||||
countdown = signal(0);
|
countdown = signal(0);
|
||||||
timer: any;
|
timer: NodeJS.Timeout | undefined;
|
||||||
|
returnUrl = '';
|
||||||
|
fb: FormBuilder = inject(FormBuilder);
|
||||||
|
authService: AuthService = inject(AuthService);
|
||||||
|
router: Router = inject(Router);
|
||||||
|
route: ActivatedRoute = inject(ActivatedRoute);
|
||||||
|
|
||||||
constructor(private fb: FormBuilder, private authService: AuthService) {
|
constructor() {
|
||||||
this.emailForm = this.fb.group({
|
this.emailForm = this.fb.group({
|
||||||
email: ['', [Validators.required, Validators.email]],
|
email: ['', [Validators.required, Validators.email]],
|
||||||
});
|
});
|
||||||
@ -25,21 +33,42 @@ export class OtpComponent {
|
|||||||
this.otpForm = this.fb.group({
|
this.otpForm = this.fb.group({
|
||||||
otp: ['', [Validators.required, Validators.pattern(/^[0-9]{6}$/)]],
|
otp: ['', [Validators.required, Validators.pattern(/^[0-9]{6}$/)]],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if(this.authService.isLoggedIn()){
|
||||||
|
this.router.navigate(['']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
sendOtp() {
|
sendOtp() {
|
||||||
if (this.emailForm.invalid) return;
|
if (this.emailForm.invalid) return;
|
||||||
const email = this.emailForm.value.email;
|
const email = this.emailForm.value.email;
|
||||||
|
|
||||||
this.authService.sendOtp(email).subscribe(() => {
|
this.authService.sendOtp(email).subscribe({
|
||||||
|
next: () => {
|
||||||
this.isOtpSent.set(true);
|
this.isOtpSent.set(true);
|
||||||
this.message.set('OTP sent successfully!');
|
this.isError.set(false);
|
||||||
|
this.message.set(`OTP sent to ${email}.`);
|
||||||
this.startTimer(30); // 30 seconds countdown
|
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.');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
resendOtp() {
|
resendOtp() {
|
||||||
if (this.countdown() > 0) return;
|
if (this.countdown() > 0) return;
|
||||||
|
this.otpForm.reset();
|
||||||
this.sendOtp();
|
this.sendOtp();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,13 +87,14 @@ export class OtpComponent {
|
|||||||
const { otp: otpCode } = this.otpForm.value;
|
const { otp: otpCode } = this.otpForm.value;
|
||||||
|
|
||||||
this.authService.verifyOtp(userId, otpCode).subscribe({
|
this.authService.verifyOtp(userId, otpCode).subscribe({
|
||||||
next: (res) => {
|
next: () => {
|
||||||
this.isVerified.set(true);
|
this.isVerified.set(true);
|
||||||
this.message.set(res.message || 'OTP verified successfully ✅');
|
this.router.navigateByUrl(this.returnUrl); // Navigate to dashboard or desired route after successful verification
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
|
this.isError.set(true);
|
||||||
if (err.status === 401 && err.error?.message) {
|
if (err.status === 401 && err.error?.message) {
|
||||||
this.message.set(err.error.message); // "OTP Expired" or "Invalid OTP"
|
this.message.set(err.error.message);
|
||||||
} else {
|
} else {
|
||||||
this.message.set('Something went wrong. Please try again.');
|
this.message.set('Something went wrong. Please try again.');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,17 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { CanActivateFn } from '@angular/router';
|
||||||
|
|
||||||
import { AuthGuard } from './auth-guard';
|
import { authGuard } from './auth-guard';
|
||||||
|
|
||||||
describe('AuthGuard', () => {
|
describe('authGuard', () => {
|
||||||
let guard: AuthGuard;
|
const executeGuard: CanActivateFn = (...guardParameters) =>
|
||||||
|
TestBed.runInInjectionContext(() => authGuard(...guardParameters));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({});
|
TestBed.configureTestingModule({});
|
||||||
guard = TestBed.inject(AuthGuard);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be created', () => {
|
it('should be created', () => {
|
||||||
expect(guard).toBeTruthy();
|
expect(executeGuard).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,14 +1,20 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { inject, PLATFORM_ID } from '@angular/core';
|
||||||
import { ActivatedRouteSnapshot, CanActivate, GuardResult, MaybeAsync, RouterStateSnapshot } from '@angular/router';
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
|
||||||
@Injectable({
|
export const authGuard: CanActivateFn = (route, state) => {
|
||||||
providedIn: 'root'
|
const platformId = inject(PLATFORM_ID);
|
||||||
})
|
|
||||||
export class AuthGuard implements CanActivate {
|
// During SSR there is no localStorage — skip the guard and let the client enforce auth after hydration
|
||||||
canActivate(
|
if (!isPlatformBrowser(platformId)) {
|
||||||
route: ActivatedRouteSnapshot,
|
|
||||||
state: RouterStateSnapshot): MaybeAsync<GuardResult> {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
const auth = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
return auth.currentToken
|
||||||
|
? true
|
||||||
|
: router.parseUrl(`login?returnUrl=${state.url}`);
|
||||||
|
};
|
||||||
|
|||||||
@ -1,32 +1,124 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
HttpRequest,
|
HttpRequest,
|
||||||
HttpHandler,
|
HttpHandlerFn,
|
||||||
HttpEvent,
|
HttpEvent,
|
||||||
HttpInterceptor
|
HttpErrorResponse,
|
||||||
|
HttpInterceptorFn
|
||||||
} from '@angular/common/http';
|
} from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { BehaviorSubject, catchError, filter, Observable, switchMap, take, throwError } from 'rxjs';
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
|
||||||
@Injectable()
|
let refreshInProgress = false;
|
||||||
export class AuthInterceptor implements HttpInterceptor {
|
const refreshSubject = new BehaviorSubject<string | null>(null);
|
||||||
|
|
||||||
constructor(public authSvc: AuthService) {}
|
export const AuthInterceptor: HttpInterceptorFn = (
|
||||||
|
req: HttpRequest<unknown>,
|
||||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
next: HttpHandlerFn
|
||||||
const apiKey = this.authSvc.getApiKey();
|
): Observable<HttpEvent<unknown>> => {
|
||||||
let headers = request.headers;
|
|
||||||
|
|
||||||
|
const authSvc: AuthService = inject(AuthService);
|
||||||
|
let headers = req.headers;
|
||||||
|
// add API key
|
||||||
|
const apiKey = authSvc.getApiKey();
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
headers = headers.set('XApiKey', apiKey);
|
headers = headers.set('XApiKey', apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure Content-Type is JSON for POST/PUT if missing
|
// add content type if needed
|
||||||
if (!(request.body instanceof FormData) && !headers.has('Content-Type') && (request.method === 'POST' || request.method === 'PUT')) {
|
if (!(req.body instanceof FormData) &&
|
||||||
|
(req.method === 'POST' || req.method === 'PUT') &&
|
||||||
|
!headers.has('Content-Type')) {
|
||||||
|
|
||||||
headers = headers.set('Content-Type', 'application/json');
|
headers = headers.set('Content-Type', 'application/json');
|
||||||
}
|
}
|
||||||
|
|
||||||
const authReq = request.clone({ headers });
|
// add access token
|
||||||
return next.handle(authReq);
|
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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,7 +0,0 @@
|
|||||||
import { HTTP_INTERCEPTORS } from "@angular/common/http";
|
|
||||||
import { AuthInterceptor } from "./auth-interceptor";
|
|
||||||
|
|
||||||
|
|
||||||
export const httpInterceptorProviders = [
|
|
||||||
{provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true},
|
|
||||||
]
|
|
||||||
17
src/app/interceptors/loading-interceptor.spec.ts
Normal file
17
src/app/interceptors/loading-interceptor.spec.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
|
||||||
|
import { loadingInterceptor } from './loading-interceptor';
|
||||||
|
|
||||||
|
describe('loadingInterceptor', () => {
|
||||||
|
const interceptor: HttpInterceptorFn = (req, next) =>
|
||||||
|
TestBed.runInInjectionContext(() => loadingInterceptor(req, next));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(interceptor).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
29
src/app/interceptors/loading-interceptor.ts
Normal file
29
src/app/interceptors/loading-interceptor.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
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();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
32
src/app/layout/admin-layout/admin-layout.html
Normal file
32
src/app/layout/admin-layout/admin-layout.html
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
@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>
|
||||||
|
}
|
||||||
38
src/app/layout/admin-layout/admin-layout.routes.ts
Normal file
38
src/app/layout/admin-layout/admin-layout.routes.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
import { About } from '../../admin/about/about';
|
||||||
|
import { Resume } from '../../admin/resume/resume';
|
||||||
|
import { Projects } from '../../admin/projects/projects';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin layout child routes.
|
||||||
|
*
|
||||||
|
* Notes:
|
||||||
|
* - Uses a dedicated `Routes` constant for better maintainability.
|
||||||
|
* - Titles are centralized as constants to avoid magic strings.
|
||||||
|
* - `pathMatch: 'full'` on the default route ensures exact matching.
|
||||||
|
* - Ready for lazy-loading or guards if needed in future.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const enum AdminRouteTitles {
|
||||||
|
About = 'About',
|
||||||
|
Resume = 'Resume',
|
||||||
|
Projects = 'Projects',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminLayoutRoutes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: About,
|
||||||
|
title: AdminRouteTitles.About,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'resume',
|
||||||
|
component: Resume,
|
||||||
|
title: AdminRouteTitles.Resume,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'projects',
|
||||||
|
component: Projects,
|
||||||
|
title: AdminRouteTitles.Projects,
|
||||||
|
},
|
||||||
|
];
|
||||||
0
src/app/layout/admin-layout/admin-layout.scss
Normal file
0
src/app/layout/admin-layout/admin-layout.scss
Normal file
23
src/app/layout/admin-layout/admin-layout.spec.ts
Normal file
23
src/app/layout/admin-layout/admin-layout.spec.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AdminLayout } from './admin-layout';
|
||||||
|
|
||||||
|
describe('AdminLayout', () => {
|
||||||
|
let component: AdminLayout;
|
||||||
|
let fixture: ComponentFixture<AdminLayout>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AdminLayout]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AdminLayout);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
21
src/app/layout/admin-layout/admin-layout.ts
Normal file
21
src/app/layout/admin-layout/admin-layout.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/app/services/loader.service.spec.ts
Normal file
16
src/app/services/loader.service.spec.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { LoaderService } from './loader.service';
|
||||||
|
|
||||||
|
describe('LoaderService', () => {
|
||||||
|
let service: LoaderService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
service = TestBed.inject(LoaderService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
24
src/app/services/loader.service.ts
Normal file
24
src/app/services/loader.service.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/app/shared/confirm-dialog/confirm-dialog.html
Normal file
15
src/app/shared/confirm-dialog/confirm-dialog.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<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>
|
||||||
86
src/app/shared/confirm-dialog/confirm-dialog.scss
Normal file
86
src/app/shared/confirm-dialog/confirm-dialog.scss
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/app/shared/confirm-dialog/confirm-dialog.ts
Normal file
50
src/app/shared/confirm-dialog/confirm-dialog.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/app/shared/dynamic-form/dynamic-field.ts
Normal file
10
src/app/shared/dynamic-form/dynamic-field.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
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' };
|
||||||
|
}
|
||||||
12
src/app/shared/dynamic-form/dynamic-form-config.ts
Normal file
12
src/app/shared/dynamic-form/dynamic-form-config.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
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[];
|
||||||
|
}
|
||||||
141
src/app/shared/dynamic-form/dynamic-form.html
Normal file
141
src/app/shared/dynamic-form/dynamic-form.html
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
<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