feat: enhance dynamic forms and popups for About, Contact, and Resume sections; add project detail popup
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user