From 58929ae6d43b713b37cef97c8ad3db0066563c1b Mon Sep 17 00:00:00 2001 From: Bangara Raju Kottedi Date: Sun, 15 Feb 2026 05:27:23 +0530 Subject: [PATCH] feat: enhance dynamic forms and popups for About, Contact, and Resume sections; add project detail popup --- src/app/admin/about/about.ts | 18 +- src/app/admin/contact/contact.ts | 12 +- src/app/admin/models/project.model.ts | 7 + .../project-detail-popup.html | 101 +++++++++++ .../project-detail-popup.scss | 107 +++++++++++ .../project-detail-popup.ts | 20 ++ src/app/admin/projects/projects.html | 15 +- src/app/admin/projects/projects.scss | 90 +++++++++ src/app/admin/projects/projects.ts | 171 ++++++++++++++++-- src/app/admin/resume/resume.ts | 21 +-- src/app/admin/services/admin.service.ts | 24 +-- src/app/admin/state/admin-state.service.ts | 24 +-- src/app/admin/state/admin.query.ts | 6 - src/app/admin/state/admin.store.ts | 2 - src/app/app.routes.server.ts | 6 +- src/app/auth/auth.service.ts | 6 +- src/app/auth/otp/otp.component.html | 6 + src/app/auth/otp/otp.component.scss | 34 ++++ src/app/auth/otp/otp.component.ts | 4 +- src/app/guards/auth-guard.ts | 12 +- src/app/interceptors/auth-interceptor.ts | 6 + 21 files changed, 605 insertions(+), 87 deletions(-) create mode 100644 src/app/admin/projects/project-detail-popup/project-detail-popup.html create mode 100644 src/app/admin/projects/project-detail-popup/project-detail-popup.scss create mode 100644 src/app/admin/projects/project-detail-popup/project-detail-popup.ts diff --git a/src/app/admin/about/about.ts b/src/app/admin/about/about.ts index 0884389..d5a033a 100644 --- a/src/app/admin/about/about.ts +++ b/src/app/admin/about/about.ts @@ -29,21 +29,21 @@ export class About implements OnInit, OnDestroy { popupData: Record = {}; 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(); - } } }); } diff --git a/src/app/admin/contact/contact.ts b/src/app/admin/contact/contact.ts index b851e51..a87a8ea 100644 --- a/src/app/admin/contact/contact.ts +++ b/src/app/admin/contact/contact.ts @@ -32,21 +32,21 @@ export class Contact implements OnInit, OnDestroy { popupData: Record = {}; 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: [ diff --git a/src/app/admin/models/project.model.ts b/src/app/admin/models/project.model.ts index c24d47f..38ad3f6 100644 --- a/src/app/admin/models/project.model.ts +++ b/src/app/admin/models/project.model.ts @@ -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; } \ No newline at end of file diff --git a/src/app/admin/projects/project-detail-popup/project-detail-popup.html b/src/app/admin/projects/project-detail-popup/project-detail-popup.html new file mode 100644 index 0000000..590af70 --- /dev/null +++ b/src/app/admin/projects/project-detail-popup/project-detail-popup.html @@ -0,0 +1,101 @@ + diff --git a/src/app/admin/projects/project-detail-popup/project-detail-popup.scss b/src/app/admin/projects/project-detail-popup/project-detail-popup.scss new file mode 100644 index 0000000..7bb14b1 --- /dev/null +++ b/src/app/admin/projects/project-detail-popup/project-detail-popup.scss @@ -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); + } +} diff --git a/src/app/admin/projects/project-detail-popup/project-detail-popup.ts b/src/app/admin/projects/project-detail-popup/project-detail-popup.ts new file mode 100644 index 0000000..dbd090f --- /dev/null +++ b/src/app/admin/projects/project-detail-popup/project-detail-popup.ts @@ -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); + project: IProject = inject(MAT_DIALOG_DATA); + + close(): void { + this.dialogRef.close(); + } +} diff --git a/src/app/admin/projects/projects.html b/src/app/admin/projects/projects.html index 7db6189..236ac2f 100644 --- a/src/app/admin/projects/projects.html +++ b/src/app/admin/projects/projects.html @@ -2,6 +2,9 @@

Projects

+
@@ -56,9 +59,9 @@
-
- -
+ {{project.name}}
@@ -70,6 +73,12 @@ }
+ + } diff --git a/src/app/admin/projects/projects.scss b/src/app/admin/projects/projects.scss index cc380e9..b2e9aea 100644 --- a/src/app/admin/projects/projects.scss +++ b/src/app/admin/projects/projects.scss @@ -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; } \ No newline at end of file diff --git a/src/app/admin/projects/projects.ts b/src/app/admin/projects/projects.ts index 4a783a7..9a1316d 100644 --- a/src/app/admin/projects/projects.ts +++ b/src/app/admin/projects/projects.ts @@ -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(); 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 { + return { + ...project, + categories: (project.categories ?? []).join(', '), + roles: (project.roles ?? []).join(', '), + responsibilities: (project.responsibilities ?? []).join(', '), + technologiesUsed: (project.technologiesUsed ?? []).join(', ') + }; + } + + private formDataToProject(formVal: Record): 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(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 | null) => { + if (!res) return; + const updated = this.formDataToProject(res); + this.saveProject(updated, false); + }); + } + + openAddProject(): void { + const config = this.getProjectFormConfig('Add Project'); + const data: Record = { projectId: 0 }; + + const ref = this.dialog.open(DynamicPopupComponent, { + data: { config, data }, + panelClass: 'dark-popup-panel' + }); + + ref.afterClosed() + .pipe(takeUntil(this.destroy$)) + .subscribe((res: Record | 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(); diff --git a/src/app/admin/resume/resume.ts b/src/app/admin/resume/resume.ts index 5bf06d5..748c36f 100644 --- a/src/app/admin/resume/resume.ts +++ b/src/app/admin/resume/resume.ts @@ -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' }, diff --git a/src/app/admin/services/admin.service.ts b/src/app/admin/services/admin.service.ts index 020bb2a..8b82b88 100644 --- a/src/app/admin/services/admin.service.ts +++ b/src/app/admin/services/admin.service.ts @@ -20,31 +20,31 @@ export class AdminService { return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`; } - getHobbies(candidateId: number): Observable { - return this.http.get(this.api(`/api/v1/admin/GetHobbies/${candidateId}`)); + getHobbies(): Observable { + return this.http.get(this.api('/api/v1/admin/GetHobbies')); } - getCandidateWithSocialLinks(candidateId: number): Observable { - return this.http.get(this.api(`/api/v1/admin/GetCandidateWithSocialLinks/${candidateId}`)); + getCandidateWithSocialLinks(): Observable { + return this.http.get(this.api('/api/v1/admin/GetCandidateWithSocialLinks')); } - getResume(candidateId: number): Observable { - return this.http.get(this.api(`/api/v1/admin/GetResume/${candidateId}`)); + getResume(): Observable { + return this.http.get(this.api('/api/v1/admin/GetResume')); } - getProjects(candidateId: number): Observable { - return this.http.get(this.api(`/api/v1/admin/GetProjects/${candidateId}`)); + getProjects(): Observable { + return this.http.get(this.api('/api/v1/admin/GetProjects')); } upsertProject(project: IProject): Observable { return this.http.post(this.api('/api/v1/admin/UpsertProject'), project); } - upsertHobbies(resumeId: number, hobbies: IHobby[]): Observable { - return this.http.post(this.api(`/api/v1/admin/UpsertHobbies/${resumeId}`), hobbies); + deleteProject(projectId: number): Observable { + return this.http.delete(this.api(`/api/v1/admin/DeleteProject/${projectId}`)); } - upsertResume(candidateId: number, resume: IResume): Observable { - return this.http.post(this.api(`/api/v1/admin/UpsertResume/${candidateId}`), resume); + upsertHobbies(hobbies: IHobby[]): Observable { + return this.http.post(this.api('/api/v1/admin/UpsertHobbies'), hobbies); } } diff --git a/src/app/admin/state/admin-state.service.ts b/src/app/admin/state/admin-state.service.ts index 9c0f246..ea62f3c 100644 --- a/src/app/admin/state/admin-state.service.ts +++ b/src/app/admin/state/admin-state.service.ts @@ -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 }); diff --git a/src/app/admin/state/admin.query.ts b/src/app/admin/state/admin.query.ts index 0dbe1e9..1e18205 100644 --- a/src/app/admin/state/admin.query.ts +++ b/src/app/admin/state/admin.query.ts @@ -9,8 +9,6 @@ export class AdminQuery extends Query { constructor() { super(inject(AdminStore)); } - // Candidate - candidateId$ = this.select('candidateId'); // About about$ = this.select('about'); @@ -47,8 +45,4 @@ export class AdminQuery extends Query { getResume() { return this.getValue().resume; } - - getCandidateId() { - return this.getValue().candidateId; - } } diff --git a/src/app/admin/state/admin.store.ts b/src/app/admin/state/admin.store.ts index 83ed4a5..19a7197 100644 --- a/src/app/admin/state/admin.store.ts +++ b/src/app/admin/state/admin.store.ts @@ -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, diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index ffd37b1..50188b3 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -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 } ]; diff --git a/src/app/auth/auth.service.ts b/src/app/auth/auth.service.ts index a9e713a..1b62437 100644 --- a/src/app/auth/auth.service.ts +++ b/src/app/auth/auth.service.ts @@ -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 { + logout(): void { + this.http.post(this.api('/api/v1/auth/logout'), {}).subscribe(); this.accessToken = null; this.safeRemoveToken(); this.router.navigate(['/admin/login']); - return of(); } refreshToken(): Observable { diff --git a/src/app/auth/otp/otp.component.html b/src/app/auth/otp/otp.component.html index 79c4782..315e9a3 100644 --- a/src/app/auth/otp/otp.component.html +++ b/src/app/auth/otp/otp.component.html @@ -28,6 +28,12 @@
+ @if (isError()) { +
+ + {{ message() }} +
+ } diff --git a/src/app/auth/otp/otp.component.scss b/src/app/auth/otp/otp.component.scss index 5ac85be..1678912 100644 --- a/src/app/auth/otp/otp.component.scss +++ b/src/app/auth/otp/otp.component.scss @@ -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; diff --git a/src/app/auth/otp/otp.component.ts b/src/app/auth/otp/otp.component.ts index 79675b7..676fa10 100644 --- a/src/app/auth/otp/otp.component.ts +++ b/src/app/auth/otp/otp.component.ts @@ -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.'); } } }); diff --git a/src/app/guards/auth-guard.ts b/src/app/guards/auth-guard.ts index 7ffdf78..7e47936 100644 --- a/src/app/guards/auth-guard.ts +++ b/src/app/guards/auth-guard.ts @@ -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); diff --git a/src/app/interceptors/auth-interceptor.ts b/src/app/interceptors/auth-interceptor.ts index 0909131..2e5de72 100644 --- a/src/app/interceptors/auth-interceptor.ts +++ b/src/app/interceptors/auth-interceptor.ts @@ -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(