feat: enhance dynamic forms and popups for About, Contact, and Resume sections; add project detail popup

This commit is contained in:
2026-02-15 05:27:23 +05:30
parent 55b436c7d2
commit 58929ae6d4
21 changed files with 605 additions and 87 deletions
@@ -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();
}
}
+12 -3
View File
@@ -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>
}
+90
View File
@@ -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;
}
+159 -12
View File
@@ -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();