feat: enhance dynamic forms and popups for About, Contact, and Resume sections; add project detail popup
This commit is contained in:
parent
55b436c7d2
commit
58929ae6d4
@ -29,21 +29,21 @@ export class About implements OnInit, OnDestroy {
|
||||
popupData: Record<string, unknown> = {};
|
||||
|
||||
ngOnInit(): void {
|
||||
const candidateId = this.adminQuery.getCandidateId();
|
||||
this.adminState.loadAbout(candidateId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe();
|
||||
if (!this.adminQuery.getAbout()) {
|
||||
this.adminState.loadAbout()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
openEdit(): void {
|
||||
const currentAbout = this.adminQuery.getAbout();
|
||||
const candidateId = this.adminQuery.getCandidateId();
|
||||
|
||||
this.popupConfig = {
|
||||
title: 'Edit About',
|
||||
submitLabel: 'Save',
|
||||
api: {
|
||||
save: `/api/v1/admin/UpsertHobbies/${candidateId}`,
|
||||
save: '/api/v1/admin/UpsertHobbies',
|
||||
method: 'POST'
|
||||
},
|
||||
fields: [
|
||||
@ -78,12 +78,6 @@ export class About implements OnInit, OnDestroy {
|
||||
if (res) {
|
||||
const updated = { ...currentAbout!, about: res.about, hobbies: res.hobbies ?? currentAbout!.hobbies };
|
||||
this.adminState.updateAbout(updated);
|
||||
|
||||
if (res.hobbies) {
|
||||
this.adminState.upsertHobbies(candidateId, res.hobbies)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -32,21 +32,21 @@ export class Contact implements OnInit, OnDestroy {
|
||||
popupData: Record<string, unknown> = {};
|
||||
|
||||
ngOnInit(): void {
|
||||
const candidateId = this.adminQuery.getCandidateId();
|
||||
this.adminState.loadContact(candidateId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe();
|
||||
if (!this.adminQuery.getContact()) {
|
||||
this.adminState.loadContact()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
openEdit(): void {
|
||||
const current = this.adminQuery.getContact();
|
||||
const candidateId = this.adminQuery.getCandidateId();
|
||||
|
||||
this.popupConfig = {
|
||||
title: 'Edit Contact',
|
||||
submitLabel: 'Save',
|
||||
api: {
|
||||
save: `/api/v1/admin/UpsertContact/${candidateId}`,
|
||||
save: '/api/v1/admin/UpsertContact',
|
||||
method: 'POST'
|
||||
},
|
||||
fields: [
|
||||
|
||||
@ -7,4 +7,11 @@ export interface IProject{
|
||||
responsibilities: string[];
|
||||
technologiesUsed: string[];
|
||||
imagePath: string;
|
||||
challenges: string;
|
||||
lessonsLearned: string;
|
||||
impact: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
status: string;
|
||||
resumeId: number;
|
||||
}
|
||||
@ -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: 1px solid var(--jet, #383838);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
<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">
|
||||
@ -56,9 +59,9 @@
|
||||
<a>
|
||||
|
||||
<figure class="project-img">
|
||||
<div class="project-item-icon-box">
|
||||
<ion-icon name="eye-outline"></ion-icon>
|
||||
</div>
|
||||
<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>
|
||||
@ -70,6 +73,12 @@
|
||||
}
|
||||
</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>
|
||||
}
|
||||
|
||||
|
||||
@ -4,4 +4,94 @@
|
||||
|
||||
.no-margin{
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: var(--orange-yellow-crayola);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: rgba(227, 179, 65, 0.12);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
margin-top: 2px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
font-size: 0.9rem;
|
||||
background: rgba(227, 179, 65, 0.1);
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: rgba(227, 179, 65, 0.22);
|
||||
}
|
||||
|
||||
.project-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.project-edit-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 2;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.project-item:hover .project-edit-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.project-edit-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.7) !important;
|
||||
}
|
||||
|
||||
.project-delete-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 42px;
|
||||
z-index: 2;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #ff6b6b;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.project-item:hover .project-delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.project-delete-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.7) !important;
|
||||
color: #ff4444;
|
||||
}
|
||||
@ -1,16 +1,22 @@
|
||||
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 { AdminQuery } from '../state/admin.query';
|
||||
import { AdminStateService } from '../state/admin-state.service';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
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],
|
||||
imports: [CommonModule, MatDialogModule],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class Projects implements OnInit, OnDestroy {
|
||||
@ -20,27 +26,33 @@ export class Projects implements OnInit, OnDestroy {
|
||||
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 {
|
||||
const candidateId = this.adminQuery.getCandidateId();
|
||||
this.adminState.loadProjects(candidateId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe();
|
||||
|
||||
this.projects$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.pipe(
|
||||
filter(data => data != null),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe(data => {
|
||||
if (data) {
|
||||
this.projects = data.projects;
|
||||
this.projectsCategories = data.projectsCategories;
|
||||
}
|
||||
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) {
|
||||
@ -55,6 +67,141 @@ export class Projects implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!confirm(`Are you sure you want to delete "${project.name}"?`)) 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();
|
||||
|
||||
@ -27,21 +27,21 @@ export class Resume implements OnInit, OnDestroy {
|
||||
loading$ = this.adminQuery.resumeLoading$;
|
||||
|
||||
ngOnInit(): void {
|
||||
const candidateId = this.adminQuery.getCandidateId();
|
||||
this.adminState.loadResume(candidateId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe();
|
||||
if (!this.adminQuery.getResume()) {
|
||||
this.adminState.loadResume()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
openEditEducation(): void {
|
||||
const resume = this.adminQuery.getResume();
|
||||
const candidateId = this.adminQuery.getCandidateId();
|
||||
|
||||
const config: DynamicFormConfig = {
|
||||
title: 'Edit Education',
|
||||
submitLabel: 'Save',
|
||||
api: {
|
||||
save: `/api/v1/admin/UpsertAcademics/${candidateId}`,
|
||||
save: '/api/v1/admin/UpsertAcademics',
|
||||
method: 'POST',
|
||||
bodyKey: 'academics'
|
||||
},
|
||||
@ -80,13 +80,12 @@ export class Resume implements OnInit, OnDestroy {
|
||||
|
||||
openEditExperience(): void {
|
||||
const resume = this.adminQuery.getResume();
|
||||
const candidateId = this.adminQuery.getCandidateId();
|
||||
|
||||
const config: DynamicFormConfig = {
|
||||
title: 'Edit Experience',
|
||||
submitLabel: 'Save',
|
||||
api: {
|
||||
save: `/api/v1/admin/UpsertExperiences/${candidateId}`,
|
||||
save: '/api/v1/admin/UpsertExperiences',
|
||||
method: 'POST',
|
||||
bodyKey: 'experiences'
|
||||
},
|
||||
@ -126,13 +125,12 @@ export class Resume implements OnInit, OnDestroy {
|
||||
|
||||
openEditSkills(): void {
|
||||
const resume = this.adminQuery.getResume();
|
||||
const candidateId = this.adminQuery.getCandidateId();
|
||||
|
||||
const config: DynamicFormConfig = {
|
||||
title: 'Edit Skills',
|
||||
submitLabel: 'Save',
|
||||
api: {
|
||||
save: `/api/v1/admin/UpsertSkills/${candidateId}`,
|
||||
save: '/api/v1/admin/UpsertSkills',
|
||||
method: 'POST',
|
||||
bodyKey: 'skills'
|
||||
},
|
||||
@ -169,13 +167,12 @@ export class Resume implements OnInit, OnDestroy {
|
||||
|
||||
openEditCertifications(): void {
|
||||
const resume = this.adminQuery.getResume();
|
||||
const candidateId = this.adminQuery.getCandidateId();
|
||||
|
||||
const config: DynamicFormConfig = {
|
||||
title: 'Edit Certifications',
|
||||
submitLabel: 'Save',
|
||||
api: {
|
||||
save: `/api/v1/admin/UpsertCertifications/${candidateId}`,
|
||||
save: '/api/v1/admin/UpsertCertifications',
|
||||
method: 'POST',
|
||||
bodyKey: 'certifications'
|
||||
},
|
||||
|
||||
@ -20,31 +20,31 @@ export class AdminService {
|
||||
return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||
}
|
||||
|
||||
getHobbies(candidateId: number): Observable<IAbout> {
|
||||
return this.http.get<IAbout>(this.api(`/api/v1/admin/GetHobbies/${candidateId}`));
|
||||
getHobbies(): Observable<IAbout> {
|
||||
return this.http.get<IAbout>(this.api('/api/v1/admin/GetHobbies'));
|
||||
}
|
||||
|
||||
getCandidateWithSocialLinks(candidateId: number): Observable<IContactModel> {
|
||||
return this.http.get<IContactModel>(this.api(`/api/v1/admin/GetCandidateWithSocialLinks/${candidateId}`));
|
||||
getCandidateWithSocialLinks(): Observable<IContactModel> {
|
||||
return this.http.get<IContactModel>(this.api('/api/v1/admin/GetCandidateWithSocialLinks'));
|
||||
}
|
||||
|
||||
getResume(candidateId: number): Observable<IResume> {
|
||||
return this.http.get<IResume>(this.api(`/api/v1/admin/GetResume/${candidateId}`));
|
||||
getResume(): Observable<IResume> {
|
||||
return this.http.get<IResume>(this.api('/api/v1/admin/GetResume'));
|
||||
}
|
||||
|
||||
getProjects(candidateId: number): Observable<IProjects> {
|
||||
return this.http.get<IProjects>(this.api(`/api/v1/admin/GetProjects/${candidateId}`));
|
||||
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);
|
||||
}
|
||||
|
||||
upsertHobbies(resumeId: number, hobbies: IHobby[]): Observable<IHobby[]> {
|
||||
return this.http.post<IHobby[]>(this.api(`/api/v1/admin/UpsertHobbies/${resumeId}`), hobbies);
|
||||
deleteProject(projectId: number): Observable<void> {
|
||||
return this.http.delete<void>(this.api(`/api/v1/admin/DeleteProject/${projectId}`));
|
||||
}
|
||||
|
||||
upsertResume(candidateId: number, resume: IResume): Observable<IResume> {
|
||||
return this.http.post<IResume>(this.api(`/api/v1/admin/UpsertResume/${candidateId}`), resume);
|
||||
upsertHobbies(hobbies: IHobby[]): Observable<IHobby[]> {
|
||||
return this.http.post<IHobby[]>(this.api('/api/v1/admin/UpsertHobbies'), hobbies);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,15 +13,11 @@ export class AdminStateService {
|
||||
private store = inject(AdminStore);
|
||||
private api = inject(AdminService);
|
||||
|
||||
setCandidateId(id: number) {
|
||||
this.store.update({ candidateId: id });
|
||||
}
|
||||
|
||||
// ── About ──────────────────────────────────────────────
|
||||
|
||||
loadAbout(candidateId: number) {
|
||||
loadAbout() {
|
||||
this.store.setSectionLoading('about', true);
|
||||
return this.api.getHobbies(candidateId).pipe(
|
||||
return this.api.getHobbies().pipe(
|
||||
tap({
|
||||
next: (about: IAbout) => {
|
||||
this.store.update({ about });
|
||||
@ -40,9 +36,9 @@ export class AdminStateService {
|
||||
this.store.update({ about });
|
||||
}
|
||||
|
||||
upsertHobbies(resumeId: number, hobbies: IHobby[]) {
|
||||
upsertHobbies(hobbies: IHobby[]) {
|
||||
this.store.setSectionLoading('about', true);
|
||||
return this.api.upsertHobbies(resumeId, hobbies).pipe(
|
||||
return this.api.upsertHobbies(hobbies).pipe(
|
||||
tap({
|
||||
next: (updatedHobbies: IHobby[]) => {
|
||||
this.store.update(state => ({
|
||||
@ -61,9 +57,9 @@ export class AdminStateService {
|
||||
|
||||
// ── Contact ────────────────────────────────────────────
|
||||
|
||||
loadContact(candidateId: number) {
|
||||
loadContact() {
|
||||
this.store.setSectionLoading('contact', true);
|
||||
return this.api.getCandidateWithSocialLinks(candidateId).pipe(
|
||||
return this.api.getCandidateWithSocialLinks().pipe(
|
||||
tap({
|
||||
next: (contact: IContactModel) => {
|
||||
this.store.update({ contact });
|
||||
@ -84,9 +80,9 @@ export class AdminStateService {
|
||||
|
||||
// ── Projects ───────────────────────────────────────────
|
||||
|
||||
loadProjects(candidateId: number) {
|
||||
loadProjects() {
|
||||
this.store.setSectionLoading('projects', true);
|
||||
return this.api.getProjects(candidateId).pipe(
|
||||
return this.api.getProjects().pipe(
|
||||
tap({
|
||||
next: (projects: IProjects) => {
|
||||
this.store.update({ projects });
|
||||
@ -107,9 +103,9 @@ export class AdminStateService {
|
||||
|
||||
// ── Resume ─────────────────────────────────────────────
|
||||
|
||||
loadResume(candidateId: number) {
|
||||
loadResume() {
|
||||
this.store.setSectionLoading('resume', true);
|
||||
return this.api.getResume(candidateId).pipe(
|
||||
return this.api.getResume().pipe(
|
||||
tap({
|
||||
next: (resume: IResume) => {
|
||||
this.store.update({ resume });
|
||||
|
||||
@ -9,8 +9,6 @@ export class AdminQuery extends Query<AdminState> {
|
||||
constructor() {
|
||||
super(inject(AdminStore));
|
||||
}
|
||||
// Candidate
|
||||
candidateId$ = this.select('candidateId');
|
||||
|
||||
// About
|
||||
about$ = this.select('about');
|
||||
@ -47,8 +45,4 @@ export class AdminQuery extends Query<AdminState> {
|
||||
getResume() {
|
||||
return this.getValue().resume;
|
||||
}
|
||||
|
||||
getCandidateId() {
|
||||
return this.getValue().candidateId;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,6 @@ import { IResume } from '../resume/resume.model';
|
||||
export type AdminSection = 'about' | 'contact' | 'projects' | 'resume';
|
||||
|
||||
export interface AdminState {
|
||||
candidateId: number;
|
||||
about: IAbout | null;
|
||||
contact: IContactModel | null;
|
||||
projects: IProjects | null;
|
||||
@ -19,7 +18,6 @@ export interface AdminState {
|
||||
|
||||
export function createInitialState(): AdminState {
|
||||
return {
|
||||
candidateId: 1,
|
||||
about: null,
|
||||
contact: null,
|
||||
projects: null,
|
||||
|
||||
@ -2,7 +2,11 @@ import { RenderMode, ServerRoute } from '@angular/ssr';
|
||||
|
||||
export const serverRoutes: ServerRoute[] = [
|
||||
{
|
||||
path: '**',
|
||||
path: 'admin/login',
|
||||
renderMode: RenderMode.Prerender
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
renderMode: RenderMode.Client
|
||||
}
|
||||
];
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
|
||||
import { BehaviorSubject, map, Observable, of } from 'rxjs';
|
||||
import { BehaviorSubject, map, Observable } from 'rxjs';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
@ -107,11 +107,11 @@ export class AuthService {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
logout(): Observable<void> {
|
||||
logout(): void {
|
||||
this.http.post<void>(this.api('/api/v1/auth/logout'), {}).subscribe();
|
||||
this.accessToken = null;
|
||||
this.safeRemoveToken();
|
||||
this.router.navigate(['/admin/login']);
|
||||
return of();
|
||||
}
|
||||
|
||||
refreshToken(): Observable<RefreshTokenResponse> {
|
||||
|
||||
@ -28,6 +28,12 @@
|
||||
<form [formGroup]="otpForm" (ngSubmit)="verifyOtp()">
|
||||
<label for="otp">Enter 6-digit OTP</label>
|
||||
<input id="otp" type="text" maxlength="6" formControlName="otp" placeholder="123456" />
|
||||
@if (isError()) {
|
||||
<div class="error-banner">
|
||||
<i class="fa-solid fa-circle-exclamation"></i>
|
||||
<span>{{ message() }}</span>
|
||||
</div>
|
||||
}
|
||||
<button type="submit" [disabled]="otpForm.invalid">
|
||||
Verify OTP
|
||||
</button>
|
||||
|
||||
@ -132,6 +132,40 @@ button {
|
||||
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;
|
||||
|
||||
@ -82,9 +82,9 @@ export class OtpComponent implements OnInit {
|
||||
error: (err) => {
|
||||
this.isError.set(true);
|
||||
if (err.status === 401 && err.error?.message) {
|
||||
console.log(err.error.message); // "OTP Expired" or "Invalid OTP"
|
||||
this.message.set(err.error.message);
|
||||
} else {
|
||||
console.log('Something went wrong. Please try again.');
|
||||
this.message.set('Something went wrong. Please try again.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { inject, PLATFORM_ID } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
|
||||
export const authGuard: CanActivateFn = (route, state)=> {
|
||||
export const authGuard: CanActivateFn = (route, state) => {
|
||||
const platformId = inject(PLATFORM_ID);
|
||||
|
||||
// During SSR there is no localStorage — skip the guard and let the client enforce auth after hydration
|
||||
if (!isPlatformBrowser(platformId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
|
||||
@ -52,6 +52,12 @@ export const AuthInterceptor: HttpInterceptorFn = (
|
||||
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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user