feat: enhance application structure and improve accessibility

- Refactor various components for better readability and maintainability.
- Update HTML templates to include `alt` attributes for images and `for` attributes for labels.
- Implement reactive forms in OTP component and improve token management in AuthService.
- Adjust routing to redirect to 'admin/about' by default.
- Remove deprecated interceptor implementation and streamline authentication logic.
- Add console logs for better debugging during initialization.
This commit is contained in:
Bangara Raju Kottedi 2025-11-16 17:40:00 +05:30
parent d0025d55ef
commit 38f305067a
17 changed files with 119 additions and 125 deletions

View File

@ -14,13 +14,15 @@
@for (category of model.projectsCategories; track category) { @for (category of model.projectsCategories; track category) {
<li class="filter-item"> <li class="filter-item">
<button (click)="filterProjects(category)" [ngClass]="{active: filter === category}">{{category}}</button> <button (click)="filterProjects(category)"
[ngClass]="{active: filter === category}">{{category}}</button>
</li> </li>
} }
</ul> </ul>
<div class="filter-select-box" (click)="categoryClicked = !categoryClicked"> <div class="filter-select-box" (click)="categoryClicked = !categoryClicked" tabindex="0"
(keyup.enter)="categoryClicked = !categoryClicked">
<button class="filter-select" [ngClass]="{active: categoryClicked}"> <button class="filter-select" [ngClass]="{active: categoryClicked}">
@ -58,7 +60,7 @@
<ion-icon name="eye-outline"></ion-icon> <ion-icon name="eye-outline"></ion-icon>
</div> --> </div> -->
<img src="{{imagesOrigin + project.imagePath}}" loading="lazy"> <img src="{{imagesOrigin + project.imagePath}}" alt="{{project.name}}" loading="lazy">
</figure> </figure>
<h3 class="project-title">{{project.name}}</h3> <h3 class="project-title">{{project.name}}</h3>

View File

@ -20,7 +20,7 @@
<li class="timeline-item"> <li class="timeline-item">
<h4 class="h4 timeline-item-title">{{education.degree}}{{education.degreeSpecialization != null ? " - " <h4 class="h4 timeline-item-title">{{education.degree}}{{education.degreeSpecialization !== null ? " - "
+ education.degreeSpecialization : ""}}</h4> + education.degreeSpecialization : ""}}</h4>
<span>{{education.period}}</span> <span>{{education.period}}</span>

View File

@ -1,5 +1,5 @@
import { Component, inject, OnInit } from '@angular/core'; import { Component, inject, OnInit, PLATFORM_ID } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule, isPlatformBrowser, isPlatformServer } from '@angular/common';
import { BaseComponent } from '../base.component'; import { BaseComponent } from '../base.component';
import { IResume } from './resume.model'; import { IResume } from './resume.model';
import { AdminService } from '../services/admin.service'; import { AdminService } from '../services/admin.service';
@ -11,6 +11,8 @@ import { AdminService } from '../services/admin.service';
imports: [CommonModule] imports: [CommonModule]
}) })
export class Resume extends BaseComponent<IResume> implements OnInit { export class Resume extends BaseComponent<IResume> implements OnInit {
platformId = inject(PLATFORM_ID);
constructor() { constructor() {
const svc = inject(AdminService); const svc = inject(AdminService);
super(svc); super(svc);
@ -19,6 +21,8 @@ export class Resume extends BaseComponent<IResume> implements OnInit {
ngOnInit(): void { ngOnInit(): void {
console.log("Resume component initialized"); console.log("Resume component initialized");
console.log("Server:", isPlatformServer(this.platformId));
console.log("Browser:", isPlatformBrowser(this.platformId));
if(!this.isDataLoading()) { if(!this.isDataLoading()) {
this.isDataLoading.set(true); this.isDataLoading.set(true);
this.getResume(); this.getResume();

View File

@ -1,11 +1,11 @@
import { Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { IAbout } from '../about/about.model'; import { IAbout } from '../about/about.model';
import { ICv } from '../models/cv.model'; import { ICv } from '../models/cv.model';
import { IContactModel } from '../contact/contact.model'; import { IContactModel } from '../contact/contact.model';
import { IResume } from '../resume/resume.model'; import { IResume } from '../resume/resume.model';
import { IProjects } from '../projects/projects.model'; import { IProjects } from '../projects/projects.model';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable, of, Subject } from 'rxjs'; import { Observable, of } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
@Injectable({ @Injectable({
@ -19,8 +19,7 @@ export class AdminService {
public candidateAndSocialLinks!: IContactModel; public candidateAndSocialLinks!: IContactModel;
public resume!: IResume; public resume!: IResume;
public projects!: IProjects; public projects!: IProjects;
private http: HttpClient = inject(HttpClient);
constructor(private http: HttpClient) { }
private api(path: string) { private api(path: string) {
return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`; return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;

View File

@ -1,18 +1,16 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core'; import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; import { provideHttpClient, withFetch, withInterceptors} from '@angular/common/http';
import { provideHttpClient, withFetch, withInterceptors, withInterceptorsFromDi } from '@angular/common/http';
import { httpInterceptorProviders } from './interceptors';
import { loadingInterceptor } from './interceptors/loading-interceptor'; import { loadingInterceptor } from './interceptors/loading-interceptor';
import { AuthInterceptor } from './interceptors/auth-interceptor';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(), provideZonelessChangeDetection(),
provideHttpClient(withInterceptorsFromDi(), withInterceptors([loadingInterceptor]), withFetch()), provideHttpClient(withInterceptors([loadingInterceptor, AuthInterceptor]), withFetch()),
httpInterceptorProviders,
provideRouter(routes), provideClientHydration(withEventReplay()) provideRouter(routes), provideClientHydration(withEventReplay())
] ]
}; };

View File

@ -16,7 +16,7 @@ const enum AdminRouteTitles {
export const routes: Routes = [ export const routes: Routes = [
{ {
path: '', path: '',
redirectTo: 'admin', redirectTo: 'admin/about',
pathMatch: 'full' pathMatch: 'full'
}, },
{ {
@ -24,11 +24,6 @@ export const routes: Routes = [
component: AdminLayout, component: AdminLayout,
title: 'Admin', title: 'Admin',
children: [ children: [
{
path: '',
redirectTo: 'about', // ✔ correct relative redirect
pathMatch: 'full'
},
{ {
path: 'login', path: 'login',
component: OtpComponent, component: OtpComponent,

View File

@ -13,4 +13,7 @@ import { RouterModule } from "@angular/router";
export class App { export class App {
loader = inject(LoaderService); loader = inject(LoaderService);
protected readonly title = signal('portfolio-admin'); protected readonly title = signal('portfolio-admin');
constructor(){
console.log('🎯 AppComponent initialized', { time: Date.now() });
}
} }

View File

@ -1,23 +1,13 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { OtpComponent } from './otp/otp.component';
import { ReactiveFormsModule } from '@angular/forms';
import { AuthInterceptor } from '../interceptors/auth-interceptor';
@NgModule({ @NgModule({
declarations: [
OtpComponent
],
imports: [ imports: [
CommonModule, CommonModule,
BrowserModule, BrowserModule,
ReactiveFormsModule
],
providers:[
AuthInterceptor
] ]
}) })
export class AuthModule { } export class AuthModule { }

View File

@ -20,41 +20,50 @@ export class AuthService {
private readonly baseUrl = (environment.apiUrl ?? '').replace(/\/+$/, ''); private readonly baseUrl = (environment.apiUrl ?? '').replace(/\/+$/, '');
private accessToken: string | null = null; private accessToken: string | null = null;
private platformId = inject(PLATFORM_ID); private platformId = inject(PLATFORM_ID);
private accessTokenSub = new BehaviorSubject<string | null>(null); public accessTokenSub = new BehaviorSubject<string | null>(null);
tokenReady$ = new BehaviorSubject<boolean>(false);
http = inject(HttpClient); http = inject(HttpClient);
router = inject(Router); router = inject(Router);
private readonly storageKey = 'accessToken'; private readonly storageKey = 'accessToken';
constructor() {
console.log('🔥 AuthService constructor started');
this.accessToken = this.safeGetToken();
console.log('🔥 AuthService constructor finished', { accessToken: !!this.accessToken });
}
private api(path: string) { private api(path: string) {
return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`; return `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
} }
// Call on app start, or from guard // // Call on app start, or from guard
async ensureTokenReady(): Promise<void>{ // async ensureTokenReady(): Promise<void>{
if(this.tokenReady$.value) return; // if(this.tokenReady$.value) return;
const stored = this.safeGetToken(); // const stored = this.safeGetToken();
// try to restore from storage // // try to restore from storage
if(stored){ // if(stored){
this.accessTokenSub.next(stored); // this.accessTokenSub.next(stored);
this.tokenReady$.next(true); // this.tokenReady$.next(true);
return; // return;
} // }
// // Optionally: try a silent refresh on startup to restore session using HttpOnly cookie // // // Optionally: try a silent refresh on startup to restore session using HttpOnly cookie
// try { // // try {
// const res = await firstValueFrom(this.refreshToken()); // // const res = await firstValueFrom(this.refreshToken());
// this.safeSetToken(res.accessToken); // // this.safeSetToken(res.accessToken);
// } // // }
// catch{ // // catch{
// console.warn('Silent token refresh failed on startup'); // // console.warn('Silent token refresh failed on startup');
// } // // }
// finally{ // // finally{
// this.tokenReady$.next(true); // // this.tokenReady$.next(true);
// } // // }
// }
get currentToken(): string | null {
return this.safeGetToken();
} }
safeSetToken(token: string) { safeSetToken(token: string) {
@ -62,12 +71,19 @@ export class AuthService {
if (isPlatformBrowser(this.platformId)) { if (isPlatformBrowser(this.platformId)) {
localStorage.setItem(this.storageKey, token); localStorage.setItem(this.storageKey, token);
this.accessTokenSub.next(token);
} }
} }
private safeGetToken(): string | null { private safeGetToken(): string | null {
if (isPlatformBrowser(this.platformId)) { try {
return localStorage.getItem(this.storageKey); if (isPlatformBrowser(this.platformId)) {
const token = localStorage.getItem(this.storageKey);
this.accessTokenSub.next(token);
return token;
}
} catch (e) {
console.warn('Failed to read from localStorage:', e);
} }
return null; return null;
} }
@ -77,6 +93,7 @@ export class AuthService {
if (isPlatformBrowser(this.platformId)) { if (isPlatformBrowser(this.platformId)) {
localStorage.removeItem(this.storageKey); localStorage.removeItem(this.storageKey);
this.accessTokenSub.next(null);
} }
} }
@ -100,7 +117,7 @@ export class AuthService {
} }
getAccessToken(): string | null { getAccessToken(): string | null {
return this.accessToken ?? this.safeGetToken(); return this.accessToken;
} }
logout(): Observable<void> { logout(): Observable<void> {

View File

@ -6,8 +6,8 @@
<!-- Step 1: Enter Email --> <!-- Step 1: Enter Email -->
@if (!isOtpSent()) { @if (!isOtpSent()) {
<form [formGroup]="emailForm" (ngSubmit)="sendOtp()" class="form-section"> <form [formGroup]="emailForm" (ngSubmit)="sendOtp()" class="form-section">
<label>Email Address</label> <label for="email">Email Address</label>
<input type="email" formControlName="email" placeholder="Enter your email" /> <input id="email" type="email" formControlName="email" placeholder="Enter your email" />
<button type="submit" [disabled]="emailForm.invalid">Send OTP</button> <button type="submit" [disabled]="emailForm.invalid">Send OTP</button>
</form> </form>
} }
@ -20,8 +20,8 @@
</p> </p>
<form [formGroup]="otpForm" (ngSubmit)="verifyOtp()"> <form [formGroup]="otpForm" (ngSubmit)="verifyOtp()">
<label>Enter 6-digit OTP</label> <label for="otp">Enter 6-digit OTP</label>
<input type="text" maxlength="6" formControlName="otp" placeholder="123456" /> <input id="otp" type="text" maxlength="6" formControlName="otp" placeholder="123456" />
<button type="submit" [disabled]="otpForm.invalid"> <button type="submit" [disabled]="otpForm.invalid">
Verify OTP Verify OTP
</button> </button>

View File

@ -1,12 +1,14 @@
import { Component, inject, OnInit, signal } from '@angular/core'; import { Component, inject, OnInit, signal } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { AuthService } from '../auth.service'; import { AuthService } from '../auth.service';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { CommonModule } from '@angular/common';
@Component({ @Component({
selector: 'app-otp', selector: 'app-otp',
templateUrl: './otp.component.html', templateUrl: './otp.component.html',
styleUrls: ['./otp.component.scss'] styleUrls: ['./otp.component.scss'],
imports: [ReactiveFormsModule, CommonModule]
}) })
export class OtpComponent implements OnInit { export class OtpComponent implements OnInit {
emailForm: FormGroup; emailForm: FormGroup;

View File

@ -2,19 +2,11 @@ import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router'; import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
export const authGuard: CanActivateFn = async (route, state) => { export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService); const auth = inject(AuthService);
const router = inject(Router); const router = inject(Router);
await auth.ensureTokenReady(); return auth.currentToken
? true
// No token → not logged in : router.parseUrl(`/admin/login?returnUrl=${state.url}`);
if (!auth.isLoggedIn()) {
router.navigate(['admin/login'], {
queryParams: { returnUrl: state.url }
});
return false;
}
return true;
}; };

View File

@ -1,27 +1,25 @@
import { inject, Injectable } from '@angular/core'; import { inject } from '@angular/core';
import { import {
HttpRequest, HttpRequest,
HttpHandler, HttpHandlerFn,
HttpEvent, HttpEvent,
HttpInterceptor, HttpErrorResponse,
HttpErrorResponse HttpInterceptorFn
} from '@angular/common/http'; } from '@angular/common/http';
import { BehaviorSubject, catchError, filter, Observable, switchMap, take, throwError } from 'rxjs'; import { BehaviorSubject, catchError, filter, Observable, switchMap, take, throwError } from 'rxjs';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
@Injectable() export const AuthInterceptor: HttpInterceptorFn = (
export class AuthInterceptor implements HttpInterceptor { req: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
private refreshInProgress = false; let refreshInProgress = false;
private refreshSubject = new BehaviorSubject<string | null>(null); const refreshSubject = new BehaviorSubject<string | null>(null);
authSvc: AuthService = inject(AuthService); const authSvc: AuthService = inject(AuthService);
let headers = req.headers;
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> { // add API key
const apiKey = authSvc.getApiKey();
let headers = req.headers;
// add API key
const apiKey = this.authSvc.getApiKey();
if (apiKey) { if (apiKey) {
headers = headers.set('XApiKey', apiKey); headers = headers.set('XApiKey', apiKey);
} }
@ -35,7 +33,7 @@ export class AuthInterceptor implements HttpInterceptor {
} }
// add access token // add access token
const token = this.authSvc.getAccessToken(); const token = authSvc.currentToken;
if (token) { if (token) {
headers = headers.set('Authorization', `Bearer ${token}`); headers = headers.set('Authorization', `Bearer ${token}`);
} }
@ -46,7 +44,7 @@ export class AuthInterceptor implements HttpInterceptor {
withCredentials: true withCredentials: true
}); });
return next.handle(clonedRequest).pipe( return next(clonedRequest).pipe(
catchError((err: HttpErrorResponse) => { catchError((err: HttpErrorResponse) => {
if (err.status !== 401) { if (err.status !== 401) {
@ -54,8 +52,8 @@ export class AuthInterceptor implements HttpInterceptor {
} }
// if refresh is already in progress // if refresh is already in progress
if (this.refreshInProgress) { if (refreshInProgress) {
return this.refreshSubject.pipe( return refreshSubject.pipe(
filter(t => t !== null), filter(t => t !== null),
take(1), take(1),
switchMap(newToken => { switchMap(newToken => {
@ -65,41 +63,39 @@ export class AuthInterceptor implements HttpInterceptor {
withCredentials: true withCredentials: true
}); });
return next.handle(retryReq); return next(retryReq);
}) })
); );
} }
// start refresh // start refresh
this.refreshInProgress = true; refreshInProgress = true;
this.refreshSubject.next(null); refreshSubject.next(null);
return this.authSvc.refreshToken()!.pipe( return authSvc.refreshToken()!.pipe(
switchMap(res => { switchMap(res => {
this.refreshInProgress = false; refreshInProgress = false;
const newToken = res.accessToken; const newToken = res.accessToken;
this.authSvc.safeSetToken(newToken); authSvc.safeSetToken(newToken);
this.refreshSubject.next(newToken);
refreshSubject.next(newToken);
const retryReq = clonedRequest.clone({ const retryReq = clonedRequest.clone({
headers: clonedRequest.headers.set('Authorization', `Bearer ${newToken}`), headers: clonedRequest.headers.set('Authorization', `Bearer ${newToken}`),
withCredentials: true withCredentials: true
}); });
return next.handle(retryReq); return next(retryReq);
}), }),
catchError(refreshErr => { catchError(refreshErr => {
this.refreshInProgress = false; refreshInProgress = false;
this.refreshSubject.next(null); refreshSubject.next(null);
this.authSvc.logout(); authSvc.logout();
return throwError(() => refreshErr); return throwError(() => refreshErr);
}) })
); );
}) })
); );
} };
}

View File

@ -1,7 +0,0 @@
import { HTTP_INTERCEPTORS } from "@angular/common/http";
import { AuthInterceptor } from "./auth-interceptor";
export const httpInterceptorProviders = [
{provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true}
]

View File

@ -1,10 +1,11 @@
<main> <main>
@if(isLoggedIn()){ @if(loggedIn()){
<app-contact></app-contact> <app-contact></app-contact>
} }
<div class="main-content"> <div class="main-content">
@if(isLoggedIn()){ @if(loggedIn()){
<nav class="navbar"> <nav class="navbar">
<ul class="navbar-list"> <ul class="navbar-list">
<li class="navbar-item"> <li class="navbar-item">

View File

@ -10,12 +10,12 @@ import { AuthService } from '../../auth/auth.service';
}) })
export class AdminLayout { export class AdminLayout {
authSvc = inject(AuthService); authSvc = inject(AuthService);
isLoggedIn = signal(false); loggedIn = signal(false);
constructor() { constructor() {
this.authSvc.tokenReady$.subscribe(ready => { this.loggedIn.set(this.authSvc.currentToken !== null);
if (ready) { this.authSvc.accessTokenSub.subscribe(token => {
this.isLoggedIn.set(!!this.authSvc.getAccessToken()); this.loggedIn.set(token !== null);
}
}); });
console.log("AdminLayout constructed"); console.log("AdminLayout constructed");
} }

View File

@ -2,5 +2,7 @@ import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config'; import { appConfig } from './app/app.config';
import { App } from './app/app'; import { App } from './app/app';
console.log('🌍 bootstrapApplication called', { time: Date.now() });
bootstrapApplication(App, appConfig) bootstrapApplication(App, appConfig)
.catch((err) => console.error(err)); .catch((err) => console.error(err));