develop #8

Merged
rajukottedi merged 22 commits from develop into master 2026-02-16 10:40:47 +05:30
21 changed files with 605 additions and 87 deletions
Showing only changes of commit 58929ae6d4 - Show all commits

View File

@ -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();
}
}
});
}

View File

@ -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: [

View File

@ -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;
}

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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();
}
}

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>
}

View File

@ -5,3 +5,93 @@
.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;
}

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();

View File

@ -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'
},

View File

@ -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);
}
}

View File

@ -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 });

View File

@ -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;
}
}

View File

@ -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,

View File

@ -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
}
];

View File

@ -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> {

View File

@ -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>

View File

@ -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;

View File

@ -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.');
}
}
});

View File

@ -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);

View File

@ -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(