Chapter 1 — Angular Signals — Complete Deep Dive
1.1 — Why Signals Exist
Before Signals, Angular used a system called Zone.js to detect changes. Zone.js works by monkey-patching every asynchronous browser API — setTimeout, setInterval, Promise, fetch, event listeners — and notifying Angular after every single one of them completes.
This works but it is blunt. Angular has no idea what actually changed. It just knows something happened. So it checks everything — the entire component tree — looking for what might have changed. On a large app with hundreds of components, this means hundreds of checks after every click, every keypress, every timer tick.
Signals are Angular's answer to this. A signal is a reactive value that knows who is reading it. When a signal's value changes, Angular knows exactly which parts of the screen depend on it — and updates only those parts. Not the whole tree. Not random components. Just the specific DOM nodes that read that specific signal.
This is called fine-grained reactivity. It is faster, more predictable, and easier to reason about than the old Zone.js approach.
1.2 — signal() — Creating a Reactive Value
You create a signal with the signal() function:
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
imports: [],
template: `
<div class="counter">
<button (click)="decrement()">-</button>
<span class="count">{{ count() }}</span>
<button (click)="increment()">+</button>
</div>
`
})
export class Counter {
count = signal(0); // initial value is 0
name = signal('Angular'); // initial value is a string
isVisible = signal(true); // initial value is a boolean
increment(): void {
this.count.update(current => current + 1);
}
decrement(): void {
this.count.update(current => current - 1);
}
}
Reading a signal always requires calling it like a function with (). In the template: {{ count() }}. In TypeScript: this.count().
Updating a signal:
.set(newValue) — replaces the current value with a completely new value:
this.count.set(10);
this.name.set('TypeScript');
this.isVisible.set(false);
.update(fn) — updates based on the current value. The function receives the current value and returns the new value:
this.count.update(current => current + 1);
this.name.update(name => name.toUpperCase());
this.items.update(list => [...list, newItem]);
The difference between set and update is that set does not care about the previous value, while update uses it. When you need the current value to compute the next value — use update. When you are setting a completely new value regardless of what was there — use set.
1.3 — computed() — Derived Reactive Values
A computed signal is a read-only signal whose value is automatically derived from other signals. Whenever any signal it reads changes, the computed value automatically recalculates.
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-shopping-cart',
imports: [],
template: `
<div class="cart">
<h2>Cart Summary</h2>
<p>Items: {{ totalItems() }}</p>
<p>Subtotal: ₹{{ subtotal() }}</p>
<p>Tax (18%): ₹{{ tax() }}</p>
<p class="total">Total: ₹{{ grandTotal() }}</p>
<button (click)="addItem()">Add Item</button>
<button (click)="clearCart()">Clear</button>
</div>
`
})
export class ShoppingCart {
items = signal<{ name: string; price: number }[]>([
{ name: 'Keyboard', price: 2500 },
{ name: 'Mouse', price: 800 }
]);
// These all automatically recalculate when items changes
totalItems = computed(() => this.items().length);
subtotal = computed(() =>
this.items().reduce((sum, item) => sum + item.price, 0)
);
tax = computed(() => Math.round(this.subtotal() * 0.18));
grandTotal = computed(() => this.subtotal() + this.tax());
// Computed signals can depend on other computed signals
grandTotalFormatted = computed(() =>
`₹${this.grandTotal().toLocaleString('en-IN')}`
);
addItem(): void {
this.items.update(list => [...list, { name: 'New Item', price: 1000 }]);
// When items changes, ALL computed signals recalculate automatically
}
clearCart(): void {
this.items.set([]);
}
}
Computed signals are lazy and memoized. They only recalculate when their dependencies change. If nothing changed, they return the cached value without running the function again. This makes them very efficient even if the template reads them many times.
Computed signals are always read-only. You cannot call .set() or .update() on them — they always derive their value from their dependencies.
1.4 — effect() — Reacting to Signal Changes
An effect runs a side-effect function whenever any signal it reads changes. Side effects are things that happen outside the reactive data flow — logging, modifying the DOM directly, saving to localStorage, starting an animation.
import { Component, signal, computed, effect, inject } from '@angular/core';
@Component({
selector: 'app-theme-demo',
imports: [],
template: `
<div class="theme-demo">
<h2>Current theme: {{ theme() }}</h2>
<p>Last changed: {{ lastChanged() }}</p>
<button (click)="setTheme('light')">Light</button>
<button (click)="setTheme('dark')">Dark</button>
<button (click)="setTheme('system')">System</button>
</div>
`
})
export class ThemeDemo {
theme = signal<'light' | 'dark' | 'system'>('light');
lastChanged = signal<string>('Never');
constructor() {
// effect() must be created in a constructor or injection context
effect(() => {
// This function runs whenever theme() changes
const currentTheme = this.theme(); // reading the signal registers it as a dependency
// Side effect: update the DOM
document.body.setAttribute('data-theme', currentTheme);
// Side effect: save to localStorage
localStorage.setItem('theme', currentTheme);
// Side effect: update another signal (needs allowSignalWrites option)
console.log('Theme changed to:', currentTheme);
});
}
setTheme(theme: 'light' | 'dark' | 'system'): void {
this.theme.set(theme);
this.lastChanged.set(new Date().toLocaleTimeString());
}
}
Effects automatically track their dependencies — any signal read inside the effect function becomes a dependency. When any of those signals change, the effect re-runs.
Effects are created in the constructor (or any injection context). They automatically clean up when the component is destroyed.
When to use effect: DOM manipulation that cannot be done with data binding, integration with third-party libraries that need to react to data changes, saving data to localStorage or IndexedDB, analytics tracking.
When NOT to use effect: Deriving values from signals (use computed instead), anything that can be done with computed or template bindings.
1.5 — Input Signals — Modern @Input()
In modern Angular, you can declare @Input() properties as signals using input():
import { Component, input, computed } from '@angular/core';
@Component({
selector: 'app-product-card',
imports: [],
template: `
<div class="card">
<h3>{{ name() }}</h3>
<p class="price">₹{{ price() }}</p>
<span class="status" [class.in-stock]="inStock()" [class.out-of-stock]="!inStock()">
{{ stockStatus() }}
</span>
</div>
`
})
export class ProductCard {
// Signal-based inputs
name = input.required<string>(); // required input
price = input.required<number>(); // required input
inStock = input<boolean>(true); // optional input with default
// You can use computed() with input signals
stockStatus = computed(() => this.inStock() ? 'In Stock' : 'Out of Stock');
// The input values are read with () just like regular signals
}
Signal inputs have several advantages over decorator-based @Input():
- They are readable anywhere using
() - You can use
computed()to derive values from them - They work with change detection more efficiently
- No
ngOnChangesneeded — just usecomputed()to react to changes
The parent uses them exactly the same way:
<app-product-card
[name]="product.name"
[price]="product.price"
[inStock]="product.inStock">
</app-product-card>
1.6 — output() — Modern @Output()
Similarly, output() is the signal-based replacement for @Output():
import { Component, output } from '@angular/core';
@Component({
selector: 'app-rating',
imports: [],
template: `
<div class="rating">
@for (star of stars; track star) {
<span
class="star"
[class.filled]="star <= currentRating"
(click)="setRating(star)">
★
</span>
}
</div>
`
})
export class Rating {
// Signal-based output
ratingChanged = output<number>();
stars = [1, 2, 3, 4, 5];
currentRating = 0;
setRating(rating: number): void {
this.currentRating = rating;
this.ratingChanged.emit(rating); // same .emit() as EventEmitter
}
}
Parent usage is the same:
<app-rating (ratingChanged)="onRatingChanged($event)"></app-rating>
1.7 — model() — Two-Way Signal Binding
model() creates a two-way bindable signal. It is the signal-based equivalent of [(ngModel)] for component inputs:
import { Component, model } from '@angular/core';
@Component({
selector: 'app-toggle',
imports: [],
template: `
<div class="toggle" [class.on]="checked()" (click)="toggle()">
<div class="knob"></div>
</div>
`,
styles: [`
.toggle { width: 52px; height: 28px; background: #ccc; border-radius: 14px;
cursor: pointer; position: relative; transition: background 0.2s; }
.toggle.on { background: #0070f3; }
.knob { width: 24px; height: 24px; background: white; border-radius: 50%;
position: absolute; top: 2px; left: 2px; transition: transform 0.2s; }
.toggle.on .knob { transform: translateX(24px); }
`]
})
export class Toggle {
// model() creates a two-way bindable signal
checked = model<boolean>(false);
toggle(): void {
this.checked.update(val => !val);
}
}
The parent uses it with the two-way binding syntax:
<app-toggle [(checked)]="isEnabled"></app-toggle>
<p>Enabled: {{ isEnabled() }}</p>
When the user clicks the toggle, checked updates inside the component AND isEnabled in the parent updates simultaneously.
1.8 — Signals vs RxJS — When to Use Which
This is a question many developers ask. The answer is they serve different purposes and you will use both:
Use Signals for:
- Component state — loading flags, local data, UI state
- Shared service state — cart, auth, theme
- Derived values — anything computed from other state
@Input()and@Output()in modern components
Use RxJS for:
- HTTP requests —
HttpClientreturns Observables - Routing events — Router exposes Observables
- Complex async workflows — debouncing, retrying, combining multiple streams
- Event streams — form
valueChanges, DOM events - Time-based operations — intervals, debounce, throttle
Often you will use both together. A service might use HttpClient (Observable) to fetch data and then store it in a signal:
@Injectable({ providedIn: 'root' })
export class Products {
private http = inject(HttpClient);
products = signal<Product[]>([]);
isLoading = signal(false);
loadProducts(): void {
this.isLoading.set(true);
this.http.get<Product[]>('/api/products').subscribe({
next: data => {
this.products.set(data); // store in signal
this.isLoading.set(false);
},
error: () => this.isLoading.set(false)
});
}
}
RxJS handles the async HTTP call. Signals hold the resulting state. Components read the signals. Clean separation.
Chapter 2 — Pipes — Complete Guide
2.1 — What are Pipes?
Pipes transform data for display in templates. They take a value, apply a transformation, and return the transformed value. The transformation happens only for display — the original data in your component is unchanged.
{{ price }} → 50000
{{ price | currency:'INR' }} → ₹50,000.00
{{ date }} → Mon Jan 06 2025 00:00:00 GMT+0530
{{ date | date:'dd MMM yyyy' }} → 06 Jan 2025
{{ name }} → rahul sharma
{{ name | titlecase }} → Rahul Sharma
2.2 — All Built-in Pipes
DatePipe — format dates
today = new Date();
releaseDate = new Date('2025-01-15');
{{ today | date }} → Jan 6, 2025
{{ today | date:'short' }} → 1/6/25, 12:00 AM
{{ today | date:'longDate' }} → January 6, 2025
{{ today | date:'dd/MM/yyyy' }} → 06/01/2025
{{ today | date:'dd MMM yyyy, hh:mm a' }} → 06 Jan 2025, 12:00 AM
{{ today | date:'EEEE, MMMM d, y' }} → Monday, January 6, 2025
Import: import { DatePipe } from '@angular/common'
CurrencyPipe — format money
{{ 50000 | currency }} → $50,000.00 (default USD)
{{ 50000 | currency:'INR' }} → ₹50,000.00
{{ 50000 | currency:'INR':'code' }} → INR 50,000.00
{{ 50000 | currency:'INR':'symbol':'1.0-0' }} → ₹50,000
Import: import { CurrencyPipe } from '@angular/common'
DecimalPipe — format numbers
{{ 3.14159 | number:'1.2-2' }} → 3.14 (at least 1 digit before decimal, 2 after)
{{ 3.14159 | number:'1.4-4' }} → 3.1416 (4 decimal places)
{{ 12345.6 | number }} → 12,345.6
{{ 0.75 | percent }} → 75%
{{ 0.75 | percent:'1.1-1' }} → 75.0%
Import: import { DecimalPipe, PercentPipe } from '@angular/common'
String case pipes
{{ 'hello world' | uppercase }} → HELLO WORLD
{{ 'HELLO WORLD' | lowercase }} → hello world
{{ 'hello world' | titlecase }} → Hello World
Import: import { UpperCasePipe, LowerCasePipe, TitleCasePipe } from '@angular/common'
SlicePipe — slice arrays or strings
{{ 'Hello Angular' | slice:6 }} → Angular
{{ 'Hello Angular' | slice:6:9 }} → Ang
{{ [1,2,3,4,5] | slice:1:4 }} → [2, 3, 4]
Import: import { SlicePipe } from '@angular/common'
JsonPipe — display objects as JSON (debugging)
{{ user | json }}
→ { "id": 1, "name": "Rahul", "email": "rahul@example.com" }
Import: import { JsonPipe } from '@angular/common'
AsyncPipe — subscribe to Observables and Promises in templates
{{ data$ | async }}
We covered this deeply in Phase 9. Import: import { AsyncPipe } from '@angular/common'
Chaining pipes
You can chain multiple pipes:
{{ name | lowercase | titlecase }}
{{ today | date:'yyyy-MM-dd' | uppercase }}
{{ amount | currency:'INR' | slice:0:8 }}
2.3 — Building a Custom Pipe
Generate a pipe:
ng g p pipes/truncate --skip-tests
src/app/pipes/truncate.ts:
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'truncate'
})
export class Truncate implements PipeTransform {
transform(value: string, limit: number = 100, ellipsis: string = '...'): string {
if (!value) return '';
if (value.length <= limit) return value;
return value.substring(0, limit) + ellipsis;
}
}
Using it:
import { Truncate } from '../../pipes/truncate';
@Component({
imports: [Truncate],
template: `
<p>{{ longText | truncate }}</p>
<p>{{ longText | truncate:50 }}</p>
<p>{{ longText | truncate:30:' [read more]' }}</p>
`
})
export class Article {
longText = 'This is a very long piece of text that should be truncated after a certain number of characters.';
}
2.4 — A Time Ago Pipe
A practical custom pipe that shows human-readable time differences:
src/app/pipes/time-ago.ts:
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'timeAgo'
})
export class TimeAgo implements PipeTransform {
transform(value: Date | string | number): string {
const date = new Date(value);
const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (seconds < 60) {
return 'just now';
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `${hours} hour${hours > 1 ? 's' : ''} ago`;
}
const days = Math.floor(hours / 24);
if (days < 30) {
return `${days} day${days > 1 ? 's' : ''} ago`;
}
const months = Math.floor(days / 30);
if (months < 12) {
return `${months} month${months > 1 ? 's' : ''} ago`;
}
const years = Math.floor(months / 12);
return `${years} year${years > 1 ? 's' : ''} ago`;
}
}
<p>Posted: {{ post.createdAt | timeAgo }}</p>
<!-- Output: Posted: 3 hours ago -->
2.5 — Pure vs Impure Pipes
Pipes are pure by default. A pure pipe only runs when Angular detects a change in the input value's reference. If you pass an array and push an item to it, the reference is the same — the pure pipe does not re-run.
An impure pipe runs on every change detection cycle regardless of whether the input changed. This is less performant but sometimes necessary.
@Pipe({
name: 'filterList',
pure: false // ← impure — runs on every change detection cycle
})
export class FilterList implements PipeTransform {
transform(items: any[], filterText: string): any[] {
if (!filterText) return items;
return items.filter(item =>
item.name.toLowerCase().includes(filterText.toLowerCase())
);
}
}
Use impure pipes sparingly. They can hurt performance because they run so frequently. In most cases, it is better to do filtering/sorting in the component's TypeScript code and store the result in a signal.
Chapter 3 — Performance Optimization
3.1 — OnPush Change Detection
We covered this in Phase 3 but let's revisit it properly here.
By default, Angular checks a component for changes whenever anything happens anywhere in the application. With OnPush, Angular only checks a component when:
- An
@Input()property receives a new object reference - An event originates from the component or its children
- An Observable subscribed with
asyncpipe emits - A signal read in the template changes
- You manually call
markForCheck()
import { Component, input, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-product-card',
imports: [],
templateUrl: './product-card.html',
changeDetection: ChangeDetectionStrategy.OnPush // ← add this
})
export class ProductCard {
product = input.required<Product>();
}
The rule for OnPush is: always pass new references, never mutate. Instead of:
// WRONG with OnPush — mutating the array
this.products.push(newProduct);
// RIGHT with OnPush — creating a new array
this.products = [...this.products, newProduct];
When you use Signals, Angular's change detection automatically becomes granular and efficient — signals work perfectly with OnPush because Angular knows exactly which signals a component reads.
3.2 — @defer — Deferrable Views
@defer is one of the most powerful Angular features. It lets you defer the loading of parts of your template — both the rendering and the JavaScript download.
<!-- This entire block is deferred — not rendered or downloaded until needed -->
@defer {
<app-heavy-chart></app-heavy-chart>
}
By default, @defer loads when the browser becomes idle. But you can control exactly when it loads with triggers:
on viewport — load when the element would scroll into view:
@defer (on viewport) {
<app-comments-section></app-comments-section>
}
@placeholder {
<div class="comments-placeholder">Comments loading...</div>
}
on interaction — load when the user interacts with the placeholder:
@defer (on interaction) {
<app-rich-text-editor></app-rich-text-editor>
}
@placeholder {
<div class="editor-placeholder">Click to start editing...</div>
}
on timer — load after a delay:
@defer (on timer(3s)) {
<app-newsletter-popup></app-newsletter-popup>
}
when condition — load when a condition becomes true:
@defer (when isLoggedIn()) {
<app-premium-content></app-premium-content>
}
@loading, @placeholder, @error blocks:
@defer (on viewport; prefetch on idle) {
<app-analytics-chart [data]="chartData"></app-analytics-chart>
} @loading (minimum 500ms) {
<div class="loading-skeleton">
<div class="skeleton-bar"></div>
<div class="skeleton-bar short"></div>
</div>
} @placeholder {
<div class="chart-placeholder">📊 Chart will load when visible</div>
} @error {
<div class="error-state">Failed to load chart</div>
}
@placeholder — shows before the deferred content starts loading
@loading — shows while loading (with optional minimum to prevent flash)
@error — shows if loading fails
prefetch on idle — downloads the JavaScript in the background when the browser is idle, before the user scrolls to it. The download is ready so when they scroll it renders instantly.
@defer is powerful for optimizing initial load time. Move everything below the fold into @defer blocks and your initial bundle shrinks dramatically.
3.3 — trackBy in @for
We mentioned this in Phase 4 but it is a genuine performance optimization worth revisiting here.
When Angular renders a @for list and the data changes, without track it destroys and recreates every DOM element. With a proper track expression, Angular figures out which items are new, which moved, and which were removed — and updates only those.
<!-- Without track — Angular recreates all DOM elements when list changes -->
@for (product of products; track $index) {
<app-product-card [product]="product"></app-product-card>
}
<!-- With unique ID track — Angular updates only what changed -->
@for (product of products; track product.id) {
<app-product-card [product]="product"></app-product-card>
}
For a list of 1000 items where only 2 changed, tracking by unique ID means 2 DOM updates instead of 1000 destructions and recreations. The visual difference is dramatic.
Always track by a unique, stable identifier — database ID, UUID. Never track $index unless the list is purely static — tracking by index gives Angular no useful information about which items are actually new or moved.
3.4 — Lazy Loading Routes
We covered this in Phase 6 but it deserves a mention here as a performance optimization. Lazy loading splits your application into separate JavaScript chunks. Each chunk is only downloaded when needed.
export const routes: Routes = [
{
path: 'admin',
loadComponent: () => import('./admin/admin').then(m => m.Admin)
}
];
Every page that uses loadComponent is a separate chunk. Users only download the code for pages they visit. This is the single biggest performance win for large applications — users get faster initial load times because they are not downloading code for the admin panel on their first visit to the home page.
3.5 — Image Optimization with NgOptimizedImage
Angular provides NgOptimizedImage for optimizing images. It handles lazy loading, proper sizing, and priority loading:
import { NgOptimizedImage } from '@angular/common';
@Component({
selector: 'app-hero',
imports: [NgOptimizedImage],
template: `
<!-- Priority image — loads immediately (above the fold) -->
<img
ngSrc="/hero-image.jpg"
alt="Hero"
width="1200"
height="600"
priority>
<!-- Non-priority images load lazily -->
<img
ngSrc="/product-photo.jpg"
alt="Product"
width="400"
height="400">
`
})
export class Hero { }
ngSrc instead of src — this tells Angular to optimize the image. width and height are required to prevent layout shift. priority marks the image as important — it gets preloaded.
Chapter 4 — Angular Animations
4.1 — Setting Up Animations
Add provideAnimations() to app.config.ts:
import { provideAnimations } from '@angular/platform-browser/animations';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(),
provideAnimations() // ← add this
]
};
4.2 — Basic Animation Concepts
Angular animations use five building blocks:
trigger — defines an animation. Has a name and contains states and transitions.
state — defines the CSS styles for a particular named state.
transition — defines how to animate between two states.
animate — defines the duration, easing, and delay of an animation.
style — defines CSS styles within an animation step.
4.3 — A Complete Animation Example
src/app/pages/animated/animated.ts:
import { Component, signal } from '@angular/core';
import { trigger, state, style, transition, animate, query, stagger } from '@angular/animations';
@Component({
selector: 'app-animated',
imports: [],
templateUrl: './animated.html',
styleUrl: './animated.css',
animations: [
// Toggle visibility with fade + slide
trigger('fadeSlide', [
state('void', style({ opacity: 0, transform: 'translateY(-20px)' })),
state('*', style({ opacity: 1, transform: 'translateY(0)' })),
transition(':enter', [
animate('300ms ease-out')
]),
transition(':leave', [
animate('200ms ease-in', style({ opacity: 0, transform: 'translateY(-20px)' }))
])
]),
// Expand and collapse height
trigger('expandCollapse', [
state('collapsed', style({ height: '0', overflow: 'hidden', opacity: 0 })),
state('expanded', style({ height: '*', overflow: 'hidden', opacity: 1 })),
transition('collapsed <=> expanded', [
animate('300ms ease-in-out')
])
]),
// List items stagger animation
trigger('listAnimation', [
transition('* => *', [
query(':enter', [
style({ opacity: 0, transform: 'translateX(-20px)' }),
stagger('60ms', [
animate('400ms ease-out', style({ opacity: 1, transform: 'translateX(0)' }))
])
], { optional: true })
])
])
]
})
export class Animated {
isVisible = signal(true);
isExpanded = signal(false);
items = signal(['Item One', 'Item Two', 'Item Three']);
toggleVisibility(): void {
this.isVisible.update(v => !v);
}
toggleExpand(): void {
this.isExpanded.update(v => !v);
}
addItem(): void {
this.items.update(list => [...list, `Item ${list.length + 1}`]);
}
removeFirst(): void {
this.items.update(list => list.slice(1));
}
}
src/app/pages/animated/animated.html:
<div class="animations-demo">
<h1>Angular Animations</h1>
<section class="demo-section">
<h2>Fade + Slide In/Out</h2>
<button (click)="toggleVisibility()">
{{ isVisible() ? 'Hide' : 'Show' }}
</button>
@if (isVisible()) {
<div class="demo-box" @fadeSlide>
I animate in and out!
</div>
}
</section>
<section class="demo-section">
<h2>Expand / Collapse</h2>
<button class="accordion-header" (click)="toggleExpand()">
FAQ: What is Angular?
<span>{{ isExpanded() ? '▲' : '▼' }}</span>
</button>
<div class="accordion-content"
[@expandCollapse]="isExpanded() ? 'expanded' : 'collapsed'">
<p>
Angular is a powerful front-end framework built by Google.
It provides a complete solution for building web applications
with components, services, routing, and reactive forms.
</p>
</div>
</section>
<section class="demo-section">
<h2>Staggered List</h2>
<div class="list-controls">
<button (click)="addItem()">Add Item</button>
<button (click)="removeFirst()">Remove First</button>
</div>
<ul [@listAnimation]="items().length">
@for (item of items(); track item) {
<li>{{ item }}</li>
}
</ul>
</section>
</div>
src/app/pages/animated/animated.css:
.animations-demo {
max-width: 700px;
margin: 0 auto;
padding: 40px 24px;
}
h1 {
font-size: 32px;
font-weight: 800;
color: #1a1a2e;
margin-bottom: 36px;
}
.demo-section {
background: white;
border-radius: 12px;
padding: 28px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
}
.demo-section h2 {
font-size: 18px;
font-weight: 700;
color: #1a1a2e;
margin-bottom: 16px;
}
button {
background: #0070f3;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
margin-right: 8px;
transition: background 0.2s;
}
button:hover { background: #005ac1; }
.demo-box {
background: #f0f7ff;
border: 2px solid #0070f3;
border-radius: 8px;
padding: 20px;
margin-top: 16px;
color: #0070f3;
font-weight: 600;
}
.accordion-header {
width: 100%;
background: #f5f7fa;
color: #1a1a2e;
border: 1px solid #e0e0e0;
padding: 14px 18px;
border-radius: 8px;
display: flex;
justify-content: space-between;
text-align: left;
}
.accordion-content {
overflow: hidden;
}
.accordion-content p {
padding: 16px 18px;
color: #555;
line-height: 1.7;
border: 1px solid #e0e0e0;
border-top: none;
border-radius: 0 0 8px 8px;
}
.list-controls { margin-bottom: 16px; }
ul {
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
li {
background: #f5f7fa;
padding: 12px 16px;
border-radius: 8px;
color: #1a1a2e;
font-size: 15px;
}
Chapter 5 — Testing with Vitest
5.1 — Why Testing Matters
Testing might feel like extra work but it pays off enormously. Tests:
- Catch bugs before users do
- Make refactoring safe — change code and your tests tell you if you broke something
- Document how code is supposed to work
- Give you confidence to add features without fear of breaking existing ones
Angular uses Vitest as its test runner. Vitest is fast, modern, and has a great developer experience.
5.2 — Running Tests
ng test
Vitest runs all .spec.ts files and shows you pass/fail results. It watches for file changes and re-runs relevant tests automatically.
5.3 — Testing a Service
Let's test the Cart service:
src/app/services/cart.spec.ts:
import { TestBed } from '@angular/core/testing';
import { Cart } from './cart';
describe('Cart Service', () => {
let service: Cart;
beforeEach(() => {
// TestBed creates an Angular testing module
TestBed.configureTestingModule({});
service = TestBed.inject(Cart);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should start with empty cart', () => {
expect(service.cartItems().length).toBe(0);
expect(service.totalItems()).toBe(0);
expect(service.totalPrice()).toBe(0);
expect(service.isEmpty()).toBe(true);
});
it('should add an item to the cart', () => {
service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });
expect(service.cartItems().length).toBe(1);
expect(service.totalItems()).toBe(1);
expect(service.totalPrice()).toBe(2500);
expect(service.isEmpty()).toBe(false);
});
it('should increase quantity when same item is added twice', () => {
service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });
service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });
expect(service.cartItems().length).toBe(1); // still 1 unique item
expect(service.totalItems()).toBe(2); // but quantity is 2
expect(service.totalPrice()).toBe(5000);
});
it('should add different items as separate entries', () => {
service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });
service.addItem({ id: 2, name: 'Mouse', price: 800, imageUrl: '' });
expect(service.cartItems().length).toBe(2);
expect(service.totalItems()).toBe(2);
expect(service.totalPrice()).toBe(3300);
});
it('should remove an item', () => {
service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });
service.removeItem(1);
expect(service.cartItems().length).toBe(0);
expect(service.isEmpty()).toBe(true);
});
it('should increase quantity', () => {
service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });
service.increaseQuantity(1);
expect(service.totalItems()).toBe(2);
expect(service.totalPrice()).toBe(5000);
});
it('should decrease quantity', () => {
service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });
service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });
service.decreaseQuantity(1);
expect(service.totalItems()).toBe(1);
});
it('should remove item when quantity decreases to zero', () => {
service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });
service.decreaseQuantity(1);
expect(service.cartItems().length).toBe(0);
});
it('should clear entire cart', () => {
service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });
service.addItem({ id: 2, name: 'Mouse', price: 800, imageUrl: '' });
service.clearCart();
expect(service.cartItems().length).toBe(0);
expect(service.totalItems()).toBe(0);
expect(service.totalPrice()).toBe(0);
});
it('should correctly report if item is in cart', () => {
service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });
expect(service.isInCart(1)).toBe(true);
expect(service.isInCart(2)).toBe(false);
});
});
describe groups related tests. it (or test) defines a single test. beforeEach runs before each test — here it creates a fresh service instance so tests do not interfere with each other.
expect(value).toBe(expected) — strict equality check (like ===).
expect(value).toBeTruthy() — value is truthy.
expect(value).toBeFalsy() — value is falsy.
expect(value).toEqual(expected) — deep equality (for objects and arrays).
expect(array).toContain(item) — array contains item.
5.4 — Testing a Component
src/app/components/counter/counter.spec.ts:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Counter } from './counter';
describe('Counter Component', () => {
let component: Counter;
let fixture: ComponentFixture<Counter>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Counter] // import the standalone component
}).compileComponents();
fixture = TestBed.createComponent(Counter);
component = fixture.componentInstance;
fixture.detectChanges(); // trigger initial change detection
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should start with count of 0', () => {
expect(component.count()).toBe(0);
});
it('should increment count', () => {
component.increment();
expect(component.count()).toBe(1);
component.increment();
expect(component.count()).toBe(2);
});
it('should decrement count', () => {
component.increment();
component.decrement();
expect(component.count()).toBe(0);
});
it('should display the count in the template', () => {
fixture.detectChanges();
// Get the element that shows the count
const countElement: HTMLElement = fixture.nativeElement.querySelector('.count');
expect(countElement.textContent).toContain('0');
// Increment and check the DOM updates
component.increment();
fixture.detectChanges();
expect(countElement.textContent).toContain('1');
});
it('should respond to button click', () => {
const buttons = fixture.nativeElement.querySelectorAll('button');
const incrementButton = buttons[1]; // assuming +/count/- layout
incrementButton.click();
fixture.detectChanges();
expect(component.count()).toBe(1);
});
});
TestBed.createComponent(Counter) creates an instance of the component in a testing environment. fixture.detectChanges() triggers Angular's change detection so the template renders. fixture.nativeElement is the component's actual DOM element.
5.5 — Testing a Pipe
import { Truncate } from './truncate';
describe('Truncate Pipe', () => {
let pipe: Truncate;
beforeEach(() => {
pipe = new Truncate();
});
it('should create', () => {
expect(pipe).toBeTruthy();
});
it('should return empty string for null or undefined', () => {
expect(pipe.transform('', 50)).toBe('');
});
it('should not truncate strings shorter than limit', () => {
expect(pipe.transform('Hello', 10)).toBe('Hello');
expect(pipe.transform('Hello', 5)).toBe('Hello');
});
it('should truncate strings longer than limit', () => {
const result = pipe.transform('Hello World Angular', 10);
expect(result).toBe('Hello Worl...');
});
it('should use custom ellipsis', () => {
const result = pipe.transform('Hello World Angular', 10, ' [more]');
expect(result).toBe('Hello Worl [more]');
});
});
Pipes are pure TypeScript classes with a transform method — they are the easiest things to test because you just create an instance and call the method.
Chapter 6 — Building for Production and Deployment
6.1 — The Production Build
ng build
or specifically for production:
ng build --configuration=production
The production build:
- Minifies all JavaScript and CSS — removes whitespace, shortens variable names
- Tree-shakes — removes unused code
- Ahead-of-Time (AOT) compilation — Angular templates are compiled to JavaScript at build time instead of in the browser
- Output hashing — adds a hash to filenames so browsers know when to download new versions
- Source maps — optional, for debugging production issues
The output goes to dist/your-app-name/browser/.
6.2 — Analyzing Bundle Size
To understand what is making your bundle large:
ng build --stats-json
Then install and run webpack-bundle-analyzer:
npm install --save-dev webpack-bundle-analyzer
npx webpack-bundle-analyzer dist/your-app/browser/stats.json
This opens a visual interactive treemap showing exactly what is in your bundle and how large each piece is. Use this to identify large dependencies you might be able to replace or lazy load.
6.3 — Deploying to Netlify
Netlify is one of the easiest deployment platforms for Angular apps.
- Build your app:
ng build - Go to netlify.com and sign in
- Drag and drop the
dist/your-app/browser/folder onto Netlify - Your app is live in seconds
For automatic deployment from GitHub:
- Push your project to GitHub
- In Netlify, click "New site from Git"
- Connect your GitHub repository
- Build command:
ng build - Publish directory:
dist/your-app/browser - Every push to main automatically deploys
One important thing for Angular apps on Netlify — add a _redirects file inside your public/ folder:
public/_redirects:
/* /index.html 200
This tells Netlify to serve index.html for all routes. Without this, if a user refreshes on /products/42, Netlify tries to find a file at that path, fails, and returns a 404. With this redirect rule, Netlify always serves index.html and Angular's router takes over.
6.4 — Deploying to Vercel
Vercel is another excellent platform:
- Install Vercel CLI:
npm install -g vercel - In your project:
vercel - Follow the prompts — set framework to Angular
Or connect directly to GitHub via vercel.com.
Add a vercel.json to your project root:
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}
Same reason as the Netlify redirect — all routes should serve index.html.
6.5 — Deploying to Firebase Hosting
Firebase Hosting is Google's hosting platform and has excellent Angular integration:
npm install -g firebase-tools
firebase login
firebase init hosting
When asked:
- Public directory:
dist/your-app/browser - Configure as single-page app: Yes
- Automatic builds with GitHub: Optional
Deploy:
ng build
firebase deploy
Chapter 7 — Putting It All Together — The Final Project
Let's build a complete Dev Blog application that showcases everything from all 10 phases — Signals, Pipes, Animations, HTTP with interceptors, Routing with guards, Reactive Forms, Services, RxJS, OnPush change detection, and @defer.
Project Setup
ng new dev-blog --style=css
cd dev-blog
ng g s services/blog --skip-tests
ng g s services/auth --skip-tests
ng g s services/loading --skip-tests
ng g s services/notifications --skip-tests
ng g interceptor interceptors/loading --skip-tests
ng g interceptor interceptors/auth --skip-tests
ng g p pipes/time-ago --skip-tests
ng g p pipes/truncate --skip-tests
ng g c components/navbar --skip-tests
ng g c components/toast --skip-tests
ng g c components/loader --skip-tests
ng g c pages/home --skip-tests
ng g c pages/blog --skip-tests
ng g c pages/post-detail --skip-tests
ng g c pages/login --skip-tests
ng g c pages/write --skip-tests
ng g c pages/not-found --skip-tests
ng g g guards/auth --skip-tests
App Config
src/app/app.config.ts:
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';
import { routes } from './app.routes';
import { loadingInterceptor } from './interceptors/loading';
import { authInterceptor } from './interceptors/auth';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(withInterceptors([loadingInterceptor, authInterceptor])),
provideAnimations()
]
};
Services
src/app/services/auth.ts:
import { Injectable, signal, computed, effect } from '@angular/core';
export interface AuthUser {
id: number;
name: string;
email: string;
avatar: string;
role: 'admin' | 'author' | 'reader';
}
@Injectable({ providedIn: 'root' })
export class Auth {
private currentUser = signal<AuthUser | null>(null);
private token = signal<string | null>(null);
readonly user = this.currentUser.asReadonly();
readonly isLoggedIn = computed(() => this.currentUser() !== null);
readonly isAuthor = computed(() =>
this.currentUser()?.role === 'admin' || this.currentUser()?.role === 'author'
);
readonly userName = computed(() => this.currentUser()?.name ?? 'Guest');
readonly userInitial = computed(() => this.currentUser()?.name?.[0]?.toUpperCase() ?? 'G');
constructor() {
// Persist auth state to localStorage
effect(() => {
const user = this.currentUser();
const tok = this.token();
if (user && tok) {
localStorage.setItem('auth_user', JSON.stringify(user));
localStorage.setItem('auth_token', tok);
} else {
localStorage.removeItem('auth_user');
localStorage.removeItem('auth_token');
}
});
// Restore from localStorage on app start
const savedUser = localStorage.getItem('auth_user');
const savedToken = localStorage.getItem('auth_token');
if (savedUser && savedToken) {
this.currentUser.set(JSON.parse(savedUser));
this.token.set(savedToken);
}
}
getToken(): string | null {
return this.token();
}
login(email: string, password: string): boolean {
const mockUsers: (AuthUser & { password: string })[] = [
{ id: 1, name: 'Rahul Sharma', email: 'rahul@blog.com', password: 'password',
avatar: 'R', role: 'admin' },
{ id: 2, name: 'Priya Patel', email: 'priya@blog.com', password: 'password',
avatar: 'P', role: 'author' },
{ id: 3, name: 'Amit Kumar', email: 'amit@blog.com', password: 'password',
avatar: 'A', role: 'reader' }
];
const found = mockUsers.find(u => u.email === email && u.password === password);
if (found) {
const { password: _, ...user } = found;
this.currentUser.set(user);
this.token.set(`mock-jwt-token-${user.id}`);
return true;
}
return false;
}
logout(): void {
this.currentUser.set(null);
this.token.set(null);
}
}
src/app/services/loading.ts:
import { Injectable, signal, computed } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class Loading {
private count = signal(0);
readonly isLoading = computed(() => this.count() > 0);
increment() { this.count.update(n => n + 1); }
decrement() { this.count.update(n => Math.max(0, n - 1)); }
}
src/app/services/notifications.ts:
import { Injectable, signal } from '@angular/core';
export interface Toast {
id: number;
message: string;
type: 'success' | 'error' | 'info';
}
@Injectable({ providedIn: 'root' })
export class Notifications {
private list = signal<Toast[]>([]);
readonly toasts = this.list.asReadonly();
private id = 1;
show(message: string, type: Toast['type'] = 'info'): void {
const toast: Toast = { id: this.id++, message, type };
this.list.update(t => [...t, toast]);
setTimeout(() => this.dismiss(toast.id), 4000);
}
success(msg: string) { this.show(msg, 'success'); }
error(msg: string) { this.show(msg, 'error'); }
info(msg: string) { this.show(msg, 'info'); }
dismiss(id: number) {
this.list.update(t => t.filter(n => n.id !== id));
}
}
src/app/services/blog.ts:
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
export interface Post {
id: number;
userId: number;
title: string;
body: string;
createdAt: Date;
category: string;
readTime: number;
authorName: string;
}
@Injectable({ providedIn: 'root' })
export class Blog {
private http = inject(HttpClient);
getPosts(): Observable<Post[]> {
return this.http.get<any[]>('https://jsonplaceholder.typicode.com/posts').pipe(
map(posts => posts.slice(0, 12).map((p, i) => ({
...p,
createdAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000),
category: ['Angular', 'TypeScript', 'RxJS', 'JavaScript'][i % 4],
readTime: Math.floor(Math.random() * 8) + 3,
authorName: ['Rahul Sharma', 'Priya Patel', 'Amit Kumar'][i % 3]
}))),
catchError(() => of([]))
);
}
getPostById(id: number): Observable<Post> {
return this.http.get<any>(`https://jsonplaceholder.typicode.com/posts/${id}`).pipe(
map(p => ({
...p,
createdAt: new Date(),
category: 'Angular',
readTime: 6,
authorName: 'Rahul Sharma'
}))
);
}
}
Interceptors
src/app/interceptors/loading.ts:
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { finalize } from 'rxjs/operators';
import { Loading } from '../services/loading';
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
const loading = inject(Loading);
loading.increment();
return next(req).pipe(finalize(() => loading.decrement()));
};
src/app/interceptors/auth.ts:
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Auth } from '../services/auth';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(Auth);
const token = auth.getToken();
if (token) {
return next(req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`)
}));
}
return next(req);
};
Auth Guard
src/app/guards/auth.ts:
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Auth } from '../services/auth';
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(Auth);
const router = inject(Router);
if (auth.isLoggedIn()) return true;
router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
return false;
};
Pipes
src/app/pipes/time-ago.ts:
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'timeAgo' })
export class TimeAgo implements PipeTransform {
transform(value: Date | string): string {
const date = new Date(value);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return date.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' });
}
}
src/app/pipes/truncate.ts:
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'truncate' })
export class Truncate implements PipeTransform {
transform(value: string, limit = 120): string {
if (!value || value.length <= limit) return value;
return value.substring(0, limit) + '...';
}
}
Navbar Component
src/app/components/navbar/navbar.ts:
import { Component, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { Auth } from '../../services/auth';
@Component({
selector: 'app-navbar',
imports: [RouterLink, RouterLinkActive],
templateUrl: './navbar.html',
styleUrl: './navbar.css'
})
export class Navbar {
auth = inject(Auth);
}
src/app/components/navbar/navbar.html:
<nav>
<a routerLink="/" class="brand">
<span class="brand-icon">✦</span>
DevBlog
</a>
<div class="nav-center">
<a routerLink="/" routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }">Home</a>
<a routerLink="/blog" routerLinkActive="active">Blog</a>
@if (auth.isAuthor()) {
<a routerLink="/write" routerLinkActive="active">Write</a>
}
</div>
<div class="nav-right">
@if (auth.isLoggedIn()) {
<div class="user-pill">
<div class="avatar">{{ auth.userInitial() }}</div>
<span>{{ auth.userName() }}</span>
</div>
<button class="logout-btn" (click)="auth.logout()">Logout</button>
} @else {
<a routerLink="/login" class="login-btn">Sign In</a>
}
</div>
</nav>
src/app/components/navbar/navbar.css:
nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40px;
height: 64px;
background: white;
border-bottom: 1px solid #f0f0f0;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 20px;
font-weight: 800;
color: #1a1a2e;
text-decoration: none;
letter-spacing: -0.5px;
}
.brand-icon { color: #0070f3; font-size: 16px; }
.nav-center {
display: flex;
gap: 4px;
}
.nav-center a {
color: #555;
text-decoration: none;
padding: 7px 14px;
border-radius: 8px;
font-size: 15px;
transition: all 0.2s;
}
.nav-center a:hover { color: #0070f3; background: #f0f7ff; }
.nav-center a.active { color: #0070f3; font-weight: 600; background: #f0f7ff; }
.nav-right {
display: flex;
align-items: center;
gap: 12px;
}
.user-pill {
display: flex;
align-items: center;
gap: 8px;
background: #f5f5f5;
padding: 4px 12px 4px 4px;
border-radius: 20px;
}
.avatar {
width: 30px; height: 30px;
background: #0070f3;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
}
.user-pill span {
font-size: 14px;
color: #333;
font-weight: 500;
}
.logout-btn {
background: none;
border: 1px solid #ddd;
color: #666;
padding: 6px 14px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.logout-btn:hover { border-color: #e94560; color: #e94560; }
.login-btn {
background: #0070f3;
color: white;
padding: 8px 18px;
border-radius: 8px;
text-decoration: none;
font-size: 14px;
font-weight: 600;
transition: background 0.2s;
}
.login-btn:hover { background: #005ac1; }
Toast Component
src/app/components/toast/toast.ts:
import { Component, inject } from '@angular/core';
import { NgClass } from '@angular/common';
import { trigger, transition, style, animate } from '@angular/animations';
import { Notifications } from '../../services/notifications';
@Component({
selector: 'app-toast',
imports: [NgClass],
animations: [
trigger('toastAnimation', [
transition(':enter', [
style({ opacity: 0, transform: 'translateX(100%)' }),
animate('250ms ease-out', style({ opacity: 1, transform: 'translateX(0)' }))
]),
transition(':leave', [
animate('200ms ease-in', style({ opacity: 0, transform: 'translateX(100%)' }))
])
])
],
template: `
<div class="toast-container">
@for (toast of notifications.toasts(); track toast.id) {
<div class="toast" [ngClass]="'toast-' + toast.type" @toastAnimation>
<span>{{ toast.message }}</span>
<button (click)="notifications.dismiss(toast.id)">×</button>
</div>
}
</div>
`,
styles: [`
.toast-container {
position: fixed; top: 80px; right: 20px;
z-index: 9999; display: flex; flex-direction: column; gap: 10px;
}
.toast {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px; border-radius: 10px; font-size: 14px;
min-width: 300px; font-weight: 500;
box-shadow: 0 4px 20px rgba(0,0,0,0.12);
}
.toast-success { background: #ecfdf5; color: #065f46; border-left: 4px solid #10b981; }
.toast-error { background: #fef2f2; color: #991b1b; border-left: 4px solid #ef4444; }
.toast-info { background: #eff6ff; color: #1e40af; border-left: 4px solid #3b82f6; }
button { background: none; border: none; font-size: 18px; cursor: pointer;
color: inherit; margin-left: 12px; opacity: 0.6; }
button:hover { opacity: 1; }
`]
})
export class Toast {
notifications = inject(Notifications);
}
Loader Component
src/app/components/loader/loader.ts:
import { Component, inject } from '@angular/core';
import { Loading } from '../../services/loading';
@Component({
selector: 'app-loader',
imports: [],
template: `
@if (loading.isLoading()) {
<div class="loader-bar"></div>
}
`,
styles: [`
.loader-bar {
position: fixed; top: 0; left: 0; right: 0; height: 3px;
background: linear-gradient(90deg, #0070f3 0%, #64ffda 50%, #0070f3 100%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
z-index: 9999;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
`]
})
export class Loader {
loading = inject(Loading);
}
Home Page
src/app/pages/home/home.ts:
import { Component, inject, OnInit, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Blog, Post } from '../../services/blog';
import { TimeAgo } from '../../pipes/time-ago';
import { Truncate } from '../../pipes/truncate';
import { trigger, transition, style, animate, query, stagger } from '@angular/animations';
@Component({
selector: 'app-home',
imports: [RouterLink, TimeAgo, Truncate],
templateUrl: './home.html',
styleUrl: './home.css',
animations: [
trigger('heroAnimation', [
transition(':enter', [
style({ opacity: 0, transform: 'translateY(30px)' }),
animate('500ms ease-out', style({ opacity: 1, transform: 'translateY(0)' }))
])
]),
trigger('cardList', [
transition('* => *', [
query(':enter', [
style({ opacity: 0, transform: 'translateY(20px)' }),
stagger(80, [
animate('400ms ease-out', style({ opacity: 1, transform: 'translateY(0)' }))
])
], { optional: true })
])
])
]
})
export class Home implements OnInit {
private blogService = inject(Blog);
featuredPosts = signal<Post[]>([]);
isLoading = signal(true);
ngOnInit(): void {
this.blogService.getPosts().subscribe({
next: posts => {
this.featuredPosts.set(posts.slice(0, 3));
this.isLoading.set(false);
},
error: () => this.isLoading.set(false)
});
}
}
src/app/pages/home/home.html:
<div class="home">
<section class="hero" @heroAnimation>
<div class="hero-content">
<span class="hero-tag">Welcome to DevBlog</span>
<h1>Build. Learn.<br><span class="gradient-text">Grow.</span></h1>
<p>Deep-dive tutorials on Angular, TypeScript, and modern web development. Written by developers, for developers.</p>
<div class="hero-actions">
<a routerLink="/blog" class="btn-primary">Browse Articles</a>
<a routerLink="/login" class="btn-secondary">Start Writing</a>
</div>
</div>
<div class="hero-visual">
<div class="code-card">
<div class="code-line"><span class="keyword">@Component</span><span class="bracket">({</span></div>
<div class="code-line indent"><span class="prop">selector:</span> <span class="string">'app-root'</span><span class="comma">,</span></div>
<div class="code-line indent"><span class="prop">imports:</span> <span class="bracket">[</span><span class="type">RouterOutlet</span><span class="bracket">]</span></div>
<div class="code-line"><span class="bracket">})</span></div>
<div class="code-line"><span class="keyword">export class</span> <span class="type">App</span> <span class="bracket">{</span></div>
<div class="code-line indent"><span class="prop">title</span> = <span class="fn">signal</span><span class="bracket">(</span><span class="string">'DevBlog'</span><span class="bracket">)</span><span class="comma">;</span></div>
<div class="code-line"><span class="bracket">}</span></div>
</div>
</div>
</section>
<section class="featured">
<div class="section-header">
<h2>Featured Articles</h2>
<a routerLink="/blog" class="see-all">See all →</a>
</div>
@if (isLoading()) {
<div class="posts-grid">
@for (i of [1,2,3]; track i) {
<div class="skeleton-card">
<div class="sk-tag"></div>
<div class="sk-title"></div>
<div class="sk-text"></div>
<div class="sk-text short"></div>
</div>
}
</div>
} @else {
<div class="posts-grid" [@cardList]="featuredPosts().length">
@for (post of featuredPosts(); track post.id) {
<article class="post-card">
<span class="tag">{{ post.category }}</span>
<h3>{{ post.title | titlecase }}</h3>
<p>{{ post.body | truncate:100 }}</p>
<div class="card-footer">
<span class="author">{{ post.authorName }}</span>
<span class="meta">{{ post.readTime }}m · {{ post.createdAt | timeAgo }}</span>
</div>
<a [routerLink]="['/blog', post.id]" class="read-link">Read article →</a>
</article>
}
</div>
}
</section>
@defer (on viewport) {
<section class="newsletter">
<h2>Stay Updated</h2>
<p>Get the latest articles delivered straight to your inbox.</p>
<div class="newsletter-form">
<input type="email" placeholder="your@email.com">
<button>Subscribe</button>
</div>
</section>
} @placeholder {
<div style="height: 200px;"></div>
}
</div>
src/app/pages/home/home.css:
.home { max-width: 1100px; margin: 0 auto; padding: 0 24px; }
.hero {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 60px;
align-items: center;
padding: 80px 0 64px;
}
.hero-tag {
display: inline-block;
background: #eff6ff;
color: #0070f3;
padding: 4px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
margin-bottom: 16px;
}
.hero-content h1 {
font-size: 56px;
font-weight: 900;
color: #1a1a2e;
line-height: 1.1;
letter-spacing: -2px;
margin-bottom: 16px;
}
.gradient-text {
background: linear-gradient(135deg, #0070f3, #64ffda);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.hero-content p {
font-size: 17px;
color: #666;
line-height: 1.7;
margin-bottom: 28px;
max-width: 440px;
}
.hero-actions { display: flex; gap: 12px; }
.btn-primary {
background: #0070f3;
color: white;
padding: 13px 26px;
border-radius: 10px;
text-decoration: none;
font-weight: 600;
font-size: 15px;
transition: all 0.2s;
}
.btn-primary:hover {
background: #005ac1;
transform: translateY(-1px);
box-shadow: 0 8px 20px rgba(0,112,243,0.3);
}
.btn-secondary {
background: white;
color: #0070f3;
border: 2px solid #0070f3;
padding: 12px 26px;
border-radius: 10px;
text-decoration: none;
font-weight: 600;
font-size: 15px;
transition: all 0.2s;
}
.btn-secondary:hover { background: #f0f7ff; }
.code-card {
background: #0a192f;
border-radius: 16px;
padding: 28px 32px;
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.8;
box-shadow: 0 20px 60px rgba(0,0,0,0.15);
}
.code-line { display: block; }
.indent { padding-left: 20px; }
.keyword { color: #ff79c6; }
.prop { color: #8be9fd; }
.string { color: #f1fa8c; }
.type { color: #50fa7b; }
.fn { color: #bd93f9; }
.bracket { color: #ccd6f6; }
.comma { color: #ccd6f6; }
.featured { padding: 0 0 80px; }
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 28px;
}
.section-header h2 {
font-size: 28px;
font-weight: 800;
color: #1a1a2e;
}
.see-all {
color: #0070f3;
text-decoration: none;
font-size: 14px;
font-weight: 600;
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.post-card {
background: white;
border-radius: 16px;
padding: 28px;
box-shadow: 0 2px 12px rgba(0,0,0,0.07);
transition: all 0.3s;
display: flex;
flex-direction: column;
gap: 10px;
}
.post-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0,0,0,0.12);
}
.tag {
font-size: 11px;
font-weight: 700;
color: #0070f3;
text-transform: uppercase;
letter-spacing: 1px;
}
.post-card h3 {
font-size: 17px;
font-weight: 700;
color: #1a1a2e;
line-height: 1.4;
}
.post-card p {
font-size: 14px;
color: #666;
line-height: 1.6;
flex: 1;
}
.card-footer {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #aaa;
padding-top: 8px;
border-top: 1px solid #f0f0f0;
}
.author { color: #555; font-weight: 500; }
.read-link {
color: #0070f3;
text-decoration: none;
font-size: 13px;
font-weight: 600;
}
.skeleton-card {
background: white;
border-radius: 16px;
padding: 28px;
display: flex;
flex-direction: column;
gap: 12px;
}
.sk-tag, .sk-title, .sk-text {
background: #f0f0f0;
border-radius: 6px;
animation: pulse 1.5s infinite;
}
.sk-tag { height: 14px; width: 60px; }
.sk-title { height: 20px; width: 90%; }
.sk-text { height: 12px; width: 100%; }
.sk-text.short { width: 70%; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.newsletter {
background: linear-gradient(135deg, #0070f3, #0050b3);
color: white;
padding: 56px 48px;
border-radius: 20px;
text-align: center;
margin-bottom: 80px;
}
.newsletter h2 {
font-size: 32px;
font-weight: 800;
margin-bottom: 10px;
}
.newsletter p {
color: rgba(255,255,255,0.8);
font-size: 16px;
margin-bottom: 24px;
}
.newsletter-form { display: flex; gap: 12px; justify-content: center; max-width: 440px; margin: 0 auto; }
.newsletter-form input {
flex: 1;
padding: 13px 18px;
border: none;
border-radius: 10px;
font-size: 15px;
}
.newsletter-form button {
background: white;
color: #0070f3;
border: none;
padding: 13px 24px;
border-radius: 10px;
font-weight: 700;
cursor: pointer;
font-size: 14px;
}
Blog Page
src/app/pages/blog/blog.ts:
import { Component, inject, OnInit, signal, computed } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ReactiveFormsModule, FormControl } from '@angular/forms';
import { Blog as BlogService, Post } from '../../services/blog';
import { TimeAgo } from '../../pipes/time-ago';
import { Truncate } from '../../pipes/truncate';
import { TitleCasePipe } from '@angular/common';
import { debounceTime, distinctUntilChanged, switchMap, of, startWith } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-blog',
imports: [RouterLink, ReactiveFormsModule, TimeAgo, Truncate, TitleCasePipe],
templateUrl: './blog.html',
styleUrl: './blog.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BlogPage implements OnInit {
private blogService = inject(BlogService);
allPosts = signal<Post[]>([]);
filteredPosts = signal<Post[]>([]);
selectedCategory = signal<string>('all');
isLoading = signal(true);
searchControl = new FormControl('');
categories = computed(() => {
const cats = [...new Set(this.allPosts().map(p => p.category))];
return ['all', ...cats];
});
constructor() {
// Search with debounce using takeUntilDestroyed
this.searchControl.valueChanges.pipe(
startWith(''),
debounceTime(300),
distinctUntilChanged(),
takeUntilDestroyed()
).subscribe(term => {
this.applyFilters(term || '');
});
}
ngOnInit(): void {
this.blogService.getPosts().subscribe({
next: posts => {
this.allPosts.set(posts);
this.filteredPosts.set(posts);
this.isLoading.set(false);
},
error: () => this.isLoading.set(false)
});
}
setCategory(category: string): void {
this.selectedCategory.set(category);
this.applyFilters(this.searchControl.value || '');
}
applyFilters(searchTerm: string): void {
let result = this.allPosts();
if (this.selectedCategory() !== 'all') {
result = result.filter(p => p.category === this.selectedCategory());
}
if (searchTerm.trim()) {
result = result.filter(p =>
p.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
p.body.toLowerCase().includes(searchTerm.toLowerCase())
);
}
this.filteredPosts.set(result);
}
}
src/app/pages/blog/blog.html:
<div class="blog-page">
<div class="blog-header">
<h1>All Articles</h1>
<p>{{ allPosts().length }} articles across {{ categories().length - 1 }} categories</p>
</div>
<div class="blog-controls">
<div class="search-wrapper">
<span class="search-icon">🔍</span>
<input type="text" [formControl]="searchControl" placeholder="Search articles...">
</div>
<div class="categories">
@for (cat of categories(); track cat) {
<button
class="cat-btn"
[class.active]="selectedCategory() === cat"
(click)="setCategory(cat)">
{{ cat | titlecase }}
</button>
}
</div>
</div>
@if (isLoading()) {
<div class="loading-grid">
@for (i of [1,2,3,4,5,6]; track i) {
<div class="skeleton-row">
<div class="sk-tag"></div>
<div class="sk-title"></div>
<div class="sk-text"></div>
</div>
}
</div>
} @else {
<div class="posts-count">
Showing {{ filteredPosts().length }} article(s)
</div>
<div class="posts-list">
@for (post of filteredPosts(); track post.id) {
<article class="post-row">
<div class="post-main">
<span class="tag">{{ post.category }}</span>
<h2>{{ post.title | titlecase }}</h2>
<p>{{ post.body | truncate:140 }}</p>
<div class="post-meta">
<span class="author-chip">{{ post.authorName }}</span>
<span>{{ post.readTime }} min read</span>
<span>{{ post.createdAt | timeAgo }}</span>
</div>
</div>
<a [routerLink]="['/blog', post.id]" class="read-btn">Read →</a>
</article>
} @empty {
<div class="empty-state">
<h3>No articles found</h3>
<p>Try a different search term or category</p>
</div>
}
</div>
}
</div>
src/app/pages/blog/blog.css:
.blog-page {
max-width: 900px;
margin: 0 auto;
padding: 48px 24px;
}
.blog-header { margin-bottom: 32px; }
.blog-header h1 {
font-size: 40px;
font-weight: 800;
color: #1a1a2e;
margin-bottom: 6px;
}
.blog-header p { color: #888; font-size: 15px; }
.blog-controls { margin-bottom: 28px; }
.search-wrapper {
position: relative;
margin-bottom: 16px;
}
.search-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
font-size: 16px;
}
.search-wrapper input {
width: 100%;
padding: 12px 14px 12px 42px;
border: 2px solid #e0e0e0;
border-radius: 10px;
font-size: 15px;
box-sizing: border-box;
transition: border-color 0.2s;
}
.search-wrapper input:focus {
outline: none;
border-color: #0070f3;
}
.categories { display: flex; gap: 8px; flex-wrap: wrap; }
.cat-btn {
background: white;
border: 1px solid #e0e0e0;
color: #555;
padding: 6px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.cat-btn:hover { border-color: #0070f3; color: #0070f3; }
.cat-btn.active { background: #0070f3; color: white; border-color: #0070f3; }
.posts-count { font-size: 13px; color: #888; margin-bottom: 16px; }
.posts-list { display: flex; flex-direction: column; gap: 2px; }
.post-row {
background: white;
padding: 24px 28px;
border-radius: 12px;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
transition: all 0.2s;
}
.post-row:hover { box-shadow: 0 4px 20px rgba(0,0,0,0.10); transform: translateY(-1px); }
.post-main { flex: 1; }
.tag {
font-size: 11px;
font-weight: 700;
color: #0070f3;
text-transform: uppercase;
letter-spacing: 1px;
display: block;
margin-bottom: 8px;
}
.post-main h2 {
font-size: 18px;
font-weight: 700;
color: #1a1a2e;
margin-bottom: 8px;
line-height: 1.3;
}
.post-main p {
font-size: 14px;
color: #666;
line-height: 1.6;
margin-bottom: 12px;
}
.post-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: #aaa;
align-items: center;
}
.author-chip {
background: #f5f5f5;
color: #555;
padding: 2px 10px;
border-radius: 20px;
font-weight: 500;
}
.read-btn {
background: #f0f7ff;
color: #0070f3;
padding: 10px 20px;
border-radius: 8px;
text-decoration: none;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
transition: all 0.2s;
flex-shrink: 0;
}
.read-btn:hover { background: #0070f3; color: white; }
.empty-state { text-align: center; padding: 80px; color: #888; }
.empty-state h3 { font-size: 20px; margin-bottom: 8px; color: #555; }
.loading-grid { display: flex; flex-direction: column; gap: 12px; }
.skeleton-row {
background: white;
border-radius: 12px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 10px;
}
.sk-tag, .sk-title, .sk-text {
background: #f0f0f0;
border-radius: 4px;
animation: pulse 1.5s infinite;
}
.sk-tag { height: 12px; width: 60px; }
.sk-title { height: 18px; width: 70%; }
.sk-text { height: 12px; width: 90%; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
Post Detail Page
src/app/pages/post-detail/post-detail.ts:
import { Component, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { Blog, Post } from '../../services/blog';
import { TimeAgo } from '../../pipes/time-ago';
import { TitleCasePipe } from '@angular/common';
import { trigger, transition, style, animate } from '@angular/animations';
@Component({
selector: 'app-post-detail',
imports: [RouterLink, TimeAgo, TitleCasePipe],
templateUrl: './post-detail.html',
styleUrl: './post-detail.css',
animations: [
trigger('pageIn', [
transition(':enter', [
style({ opacity: 0, transform: 'translateY(16px)' }),
animate('400ms ease-out', style({ opacity: 1, transform: 'translateY(0)' }))
])
])
]
})
export class PostDetail implements OnInit {
private route = inject(ActivatedRoute);
private blogService = inject(Blog);
post = signal<Post | null>(null);
isLoading = signal(true);
notFound = signal(false);
ngOnInit(): void {
const id = Number(this.route.snapshot.params['id']);
this.blogService.getPostById(id).subscribe({
next: post => {
this.post.set(post);
this.isLoading.set(false);
if (post) document.title = post.title + ' | DevBlog';
},
error: () => {
this.notFound.set(true);
this.isLoading.set(false);
}
});
}
}
src/app/pages/post-detail/post-detail.html:
<div class="post-detail">
<div class="back-bar">
<a routerLink="/blog" class="back-link">← Back to Blog</a>
</div>
@if (isLoading()) {
<div class="loading">
<div class="spinner"></div>
</div>
}
@if (notFound()) {
<div class="not-found">
<h1>Post not found</h1>
<a routerLink="/blog">← Back to Blog</a>
</div>
}
@if (post()) {
<article @pageIn>
<header class="post-header">
<span class="category">{{ post()!.category }}</span>
<h1>{{ post()!.title | titlecase }}</h1>
<div class="post-meta">
<div class="author-info">
<div class="author-avatar">{{ post()!.authorName[0] }}</div>
<div>
<strong>{{ post()!.authorName }}</strong>
<span>{{ post()!.createdAt | timeAgo }} · {{ post()!.readTime }} min read</span>
</div>
</div>
</div>
</header>
<div class="post-body">
<p class="lead">{{ post()!.body }}</p>
@defer (on idle) {
<div class="related-section">
<h3>Continue Reading</h3>
<div class="related-links">
<a routerLink="/blog" class="related-link">← More Articles</a>
</div>
</div>
}
</div>
</article>
}
</div>
src/app/pages/post-detail/post-detail.css:
.post-detail {
max-width: 760px;
margin: 0 auto;
padding: 32px 24px 80px;
}
.back-bar { margin-bottom: 32px; }
.back-link {
color: #0070f3;
text-decoration: none;
font-size: 14px;
font-weight: 600;
}
.loading { display: flex; justify-content: center; padding: 80px; }
.spinner {
width: 40px; height: 40px;
border: 3px solid #e0e0e0;
border-top-color: #0070f3;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.not-found { text-align: center; padding: 80px 0; }
.not-found h1 { color: #555; margin-bottom: 16px; }
.not-found a { color: #0070f3; text-decoration: none; }
.post-header { margin-bottom: 40px; }
.category {
display: inline-block;
background: #eff6ff;
color: #0070f3;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 16px;
}
.post-header h1 {
font-size: 42px;
font-weight: 900;
color: #1a1a2e;
line-height: 1.2;
letter-spacing: -0.5px;
margin-bottom: 24px;
}
.post-meta { display: flex; align-items: center; }
.author-info { display: flex; align-items: center; gap: 12px; }
.author-avatar {
width: 44px; height: 44px;
background: linear-gradient(135deg, #0070f3, #64ffda);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
font-size: 18px;
}
.author-info strong { display: block; font-size: 15px; color: #1a1a2e; }
.author-info span { display: block; font-size: 13px; color: #888; margin-top: 2px; }
.post-body { border-top: 1px solid #f0f0f0; padding-top: 40px; }
.lead {
font-size: 18px;
line-height: 1.9;
color: #444;
}
.related-section { margin-top: 56px; padding-top: 32px; border-top: 1px solid #f0f0f0; }
.related-section h3 { font-size: 18px; color: #1a1a2e; margin-bottom: 16px; }
.related-link {
display: inline-block;
background: #f0f7ff;
color: #0070f3;
padding: 10px 20px;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
font-size: 14px;
}
Login Page
src/app/pages/login/login.ts:
import { Component, inject, signal } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Auth } from '../../services/auth';
import { Notifications } from '../../services/notifications';
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule],
templateUrl: './login.html',
styleUrl: './login.css'
})
export class Login {
private fb = inject(FormBuilder);
private auth = inject(Auth);
private router = inject(Router);
private route = inject(ActivatedRoute);
private notifications = inject(Notifications);
isLoading = signal(false);
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(6)]]
});
get email() { return this.form.get('email')!; }
get password() { return this.form.get('password')!; }
onLogin(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.isLoading.set(true);
const { email, password } = this.form.value;
const success = this.auth.login(email!, password!);
if (success) {
this.notifications.success(`Welcome back, ${this.auth.userName()}!`);
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
this.router.navigateByUrl(returnUrl);
} else {
this.notifications.error('Invalid email or password.');
this.isLoading.set(false);
}
}
}
src/app/pages/login/login.html:
<div class="login-page">
<div class="login-card">
<div class="login-logo">✦</div>
<h1>Welcome back</h1>
<p class="subtitle">Sign in to your DevBlog account</p>
<form [formGroup]="form" (ngSubmit)="onLogin()">
<div class="field">
<label>Email</label>
<input type="email" formControlName="email" placeholder="your@email.com">
@if (email.invalid && email.touched) {
<div class="error">
@if (email.errors?.['required']) { Enter your email }
@if (email.errors?.['email']) { Enter a valid email }
</div>
}
</div>
<div class="field">
<label>Password</label>
<input type="password" formControlName="password"
placeholder="Your password" (keyup.enter)="onLogin()">
@if (password.invalid && password.touched) {
<div class="error">Password must be at least 6 characters</div>
}
</div>
<button type="submit" [disabled]="isLoading()">
{{ isLoading() ? 'Signing in...' : 'Sign In' }}
</button>
</form>
<div class="demo-accounts">
<p>Demo accounts (all use password: <strong>password</strong>)</p>
<div class="accounts">
<span>rahul@blog.com · Admin</span>
<span>priya@blog.com · Author</span>
<span>amit@blog.com · Reader</span>
</div>
</div>
</div>
</div>
src/app/pages/login/login.css:
.login-page {
min-height: calc(100vh - 64px);
display: flex;
align-items: center;
justify-content: center;
background: #f9fafb;
padding: 40px 24px;
}
.login-card {
background: white;
padding: 48px 44px;
border-radius: 20px;
box-shadow: 0 8px 40px rgba(0,0,0,0.08);
width: 100%;
max-width: 420px;
text-align: center;
}
.login-logo {
font-size: 36px;
color: #0070f3;
margin-bottom: 16px;
}
h1 {
font-size: 28px;
font-weight: 800;
color: #1a1a2e;
margin-bottom: 8px;
}
.subtitle { color: #888; font-size: 15px; margin-bottom: 32px; }
.field { margin-bottom: 18px; text-align: left; }
label {
display: block;
font-size: 13px;
font-weight: 600;
color: #555;
margin-bottom: 7px;
}
input {
width: 100%;
padding: 12px 14px;
border: 1.5px solid #e0e0e0;
border-radius: 10px;
font-size: 15px;
transition: border-color 0.2s;
box-sizing: border-box;
}
input:focus { outline: none; border-color: #0070f3; }
input.ng-invalid.ng-touched { border-color: #ef4444; }
.error { margin-top: 5px; font-size: 12px; color: #ef4444; }
button {
width: 100%;
padding: 14px;
background: #0070f3;
color: white;
border: none;
border-radius: 10px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
margin-top: 8px;
transition: all 0.2s;
}
button:hover { background: #005ac1; transform: translateY(-1px); }
button:disabled { background: #ccc; cursor: not-allowed; transform: none; }
.demo-accounts {
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
}
.demo-accounts p { font-size: 13px; color: #888; margin-bottom: 10px; }
.accounts {
display: flex;
flex-direction: column;
gap: 4px;
}
.accounts span {
font-size: 12px;
color: #666;
background: #f5f5f5;
padding: 4px 10px;
border-radius: 6px;
}
Write Page (Protected)
src/app/pages/write/write.ts:
import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { Notifications } from '../../services/notifications';
import { Auth } from '../../services/auth';
@Component({
selector: 'app-write',
imports: [ReactiveFormsModule],
templateUrl: './write.html',
styleUrl: './write.css'
})
export class Write {
private fb = inject(FormBuilder);
private notifications = inject(Notifications);
private router = inject(Router);
auth = inject(Auth);
isSubmitting = signal(false);
form = this.fb.group({
title: ['', [Validators.required, Validators.minLength(10)]],
category: ['Angular', Validators.required],
content: ['', [Validators.required, Validators.minLength(100)]]
});
get title() { return this.form.get('title')!; }
get content() { return this.form.get('content')!; }
get wordCount(): number {
const text = this.form.get('content')?.value || '';
return text.trim().split(/\s+/).filter(Boolean).length;
}
get estimatedReadTime(): number {
return Math.max(1, Math.ceil(this.wordCount / 200));
}
onPublish(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.isSubmitting.set(true);
setTimeout(() => {
this.notifications.success('Article published successfully!');
this.router.navigate(['/blog']);
}, 1500);
}
}
src/app/pages/write/write.html:
<div class="write-page">
<div class="write-header">
<h1>Write an Article</h1>
<div class="write-meta">
<span>By {{ auth.userName() }}</span>
<span>·</span>
<span>{{ wordCount }} words · ~{{ estimatedReadTime }} min read</span>
</div>
</div>
<form [formGroup]="form" class="write-form">
<div class="field">
<input
type="text"
formControlName="title"
class="title-input"
placeholder="Article title...">
@if (title.invalid && title.touched) {
<div class="error">Title must be at least 10 characters</div>
}
</div>
<div class="field inline-field">
<label>Category</label>
<select formControlName="category">
<option>Angular</option>
<option>TypeScript</option>
<option>RxJS</option>
<option>JavaScript</option>
<option>CSS</option>
</select>
</div>
<div class="field">
<textarea
formControlName="content"
class="content-input"
rows="20"
placeholder="Write your article here... (minimum 100 characters)">
</textarea>
@if (content.invalid && content.touched) {
<div class="error">Content must be at least 100 characters</div>
}
</div>
<div class="write-actions">
<span class="char-count">{{ form.get('content')?.value?.length || 0 }} characters</span>
<button
type="button"
class="publish-btn"
(click)="onPublish()"
[disabled]="form.invalid || isSubmitting()">
{{ isSubmitting() ? 'Publishing...' : 'Publish Article' }}
</button>
</div>
</form>
</div>
src/app/pages/write/write.css:
.write-page {
max-width: 800px;
margin: 0 auto;
padding: 40px 24px 80px;
}
.write-header { margin-bottom: 32px; }
.write-header h1 {
font-size: 32px;
font-weight: 800;
color: #1a1a2e;
margin-bottom: 6px;
}
.write-meta { font-size: 13px; color: #888; display: flex; gap: 8px; }
.write-form {
background: white;
border-radius: 16px;
padding: 36px;
box-shadow: 0 2px 12px rgba(0,0,0,0.07);
}
.field { margin-bottom: 20px; }
.title-input {
width: 100%;
font-size: 28px;
font-weight: 700;
border: none;
border-bottom: 2px solid #f0f0f0;
padding: 8px 0;
color: #1a1a2e;
outline: none;
box-sizing: border-box;
transition: border-color 0.2s;
}
.title-input:focus { border-bottom-color: #0070f3; }
.inline-field {
display: flex;
align-items: center;
gap: 12px;
}
.inline-field label {
font-size: 13px;
font-weight: 600;
color: #555;
white-space: nowrap;
}
.inline-field select {
padding: 7px 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
color: #333;
}
.content-input {
width: 100%;
border: 1.5px solid #e0e0e0;
border-radius: 10px;
padding: 16px;
font-size: 16px;
line-height: 1.8;
color: #333;
resize: vertical;
font-family: inherit;
box-sizing: border-box;
transition: border-color 0.2s;
}
.content-input:focus { outline: none; border-color: #0070f3; }
.error { margin-top: 6px; font-size: 12px; color: #ef4444; }
.write-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.char-count { font-size: 13px; color: #aaa; }
.publish-btn {
background: #0070f3;
color: white;
border: none;
padding: 13px 32px;
border-radius: 10px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
}
.publish-btn:hover {
background: #005ac1;
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(0,112,243,0.3);
}
.publish-btn:disabled { background: #ccc; cursor: not-allowed; transform: none; box-shadow: none; }
Not Found Page
src/app/pages/not-found/not-found.ts:
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-not-found',
imports: [RouterLink],
template: `
<div class="not-found">
<div class="code">404</div>
<h1>Page not found</h1>
<p>The page you are looking for does not exist.</p>
<a routerLink="/">← Go Home</a>
</div>
`,
styles: [`
.not-found {
text-align: center; padding: 120px 24px;
}
.code {
font-size: 120px; font-weight: 900;
color: #f0f0f0; line-height: 1; margin-bottom: 16px;
letter-spacing: -4px;
}
h1 { font-size: 32px; font-weight: 800; color: #1a1a2e; margin-bottom: 10px; }
p { color: #888; font-size: 16px; margin-bottom: 28px; }
a {
display: inline-block; background: #0070f3; color: white;
padding: 12px 28px; border-radius: 10px; text-decoration: none;
font-weight: 600; font-size: 15px;
}
`]
})
export class NotFound { }
Routes
src/app/app.routes.ts:
import { Routes } from '@angular/router';
import { authGuard } from './guards/auth';
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./pages/home/home').then(m => m.Home)
},
{
path: 'blog',
loadComponent: () => import('./pages/blog/blog').then(m => m.BlogPage)
},
{
path: 'blog/:id',
loadComponent: () => import('./pages/post-detail/post-detail').then(m => m.PostDetail)
},
{
path: 'login',
loadComponent: () => import('./pages/login/login').then(m => m.Login)
},
{
path: 'write',
canActivate: [authGuard],
loadComponent: () => import('./pages/write/write').then(m => m.Write)
},
{
path: '**',
loadComponent: () => import('./pages/not-found/not-found').then(m => m.NotFound)
}
];
App Root
src/app/app.ts:
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Navbar } from './components/navbar/navbar';
import { Toast } from './components/toast/toast';
import { Loader } from './components/loader/loader';
@Component({
selector: 'app-root',
imports: [RouterOutlet, Navbar, Toast, Loader],
template: `
<app-loader></app-loader>
<app-toast></app-toast>
<app-navbar></app-navbar>
<router-outlet></router-outlet>
`
})
export class App { }
src/styles.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
body {
background: #f9fafb;
color: #1a1a2e;
-webkit-font-smoothing: antialiased;
}
a { text-decoration: none; }
Run ng serve -o. Your complete DevBlog application is running with everything from all 10 phases working together.
Phase 10 — Complete Summary
Here is everything you learned in this phase.
Signals deep dive — signal() creates reactive values. Read with (). Update with .set() and .update(). computed() for derived values — lazy, memoized, read-only. effect() for side effects that run when dependencies change — must be in constructor or injection context. Signal inputs with input() and input.required(). Signal outputs with output(). Two-way binding with model(). Signals and RxJS serve different purposes and are used together.
Pipes — transform display values without changing source data. Built-in: date, currency, number, percent, uppercase, lowercase, titlecase, slice, json, async. Custom pipes implement PipeTransform. Pure pipes only re-run when input reference changes. Impure pipes run every change detection cycle.
Performance — ChangeDetectionStrategy.OnPush skips unnecessary checks — requires new references not mutations. @defer with triggers (on viewport, on interaction, when condition) for deferred loading of heavy components. track by unique ID in @for for efficient DOM updates. NgOptimizedImage for optimized image loading. Lazy loaded routes with loadComponent.
Animations — provideAnimations() in app config. Five building blocks: trigger, state, transition, animate, style. :enter and :leave for element appearance and disappearance. stagger for animating lists.
Testing — Vitest as the test runner. describe for grouping, it for individual tests, beforeEach for setup. TestBed for Angular component and service testing. expect() with matchers like toBe, toEqual, toBeTruthy, toContain. Test services by injecting them. Test components with ComponentFixture and fixture.detectChanges(). Test pipes directly by instantiating them.
Production build — ng build --configuration=production for optimized output. Bundle analysis with webpack-bundle-analyzer. Deploy to Netlify, Vercel, or Firebase with proper redirect rules for SPA routing.
You Have Completed the Angular Course
You have gone through all 10 phases from TypeScript fundamentals all the way to production deployment.
Phase 1 gave you TypeScript — the foundation that makes everything else possible.
Phase 2 gave you Angular fundamentals — how Angular starts, its project structure, standalone components.
Phase 3 took components deep — lifecycle hooks, @Input, @Output, @ViewChild, content projection, change detection.
Phase 4 covered data binding and directives — interpolation, property binding, event binding, two-way binding, @if, @for, @switch, custom directives.
Phase 5 built your understanding of services and dependency injection — the architecture that keeps Angular apps organized and scalable.
Phase 6 gave you routing — navigation, route parameters, guards, lazy loading, nested routes.
Phase 7 covered forms completely — template-driven, reactive, custom validators, FormArray, async validators.
Phase 8 connected your app to the real world — HttpClient, interceptors, error handling, environment configuration.
Phase 9 unlocked reactive programming — Observables, RxJS operators, Subjects, patterns like search with debounce.
Phase 10 brought it all together — Signals, pipes, performance optimization, animations, testing, and deployment.
You now have everything you need to build real, production-quality Angular applications. The next step is to keep building — every project you build will solidify these concepts further and reveal new things to learn.
No comments:
Post a Comment