Skip to content

Movie Detail, Watchlist, and Route Guards

The final three features complete CinemaVault: the movie detail page that shows cast information, the watchlist service that persists across sessions, and the guard that protects the watchlist route.

Create src/app/services/watchlist.ts:

import { Injectable, signal, computed } from '@angular/core';
import { Movie } from '../models/movie.model';
@Injectable({ providedIn: 'root' })
export class WatchlistService {
private movies = signal<Movie[]>(this.load());
readonly items = this.movies.asReadonly();
readonly count = computed(() => this.movies().length);
has(id: number): boolean {
return this.movies().some(m => m.id === id);
}
toggle(movie: Movie): void {
if (this.has(movie.id)) {
this.movies.update(list => list.filter(m => m.id !== movie.id));
} else {
this.movies.update(list => [...list, movie]);
}
this.save();
}
private save(): void {
localStorage.setItem('cv-watchlist', JSON.stringify(this.movies()));
}
private load(): Movie[] {
try {
return JSON.parse(localStorage.getItem('cv-watchlist') ?? '[]');
} catch {
return [];
}
}
}

Create src/app/services/toast.ts:

import { Injectable, signal } from '@angular/core';
export interface Toast {
message: string;
type: 'info' | 'success' | 'error';
}
@Injectable({ providedIn: 'root' })
export class ToastService {
private timer: ReturnType<typeof setTimeout> | null = null;
readonly toast = signal<Toast | null>(null);
show(message: string, type: Toast['type'] = 'info', duration = 3000): void {
if (this.timer) clearTimeout(this.timer);
this.toast.set({ message, type });
this.timer = setTimeout(() => this.toast.set(null), duration);
}
dismiss(): void {
if (this.timer) clearTimeout(this.timer);
this.toast.set(null);
}
}
guards/watchlist-guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { WatchlistService } from '../services/watchlist';
import { ToastService } from '../services/toast';
export const watchlistGuard: CanActivateFn = () => {
const watchlist = inject(WatchlistService);
const router = inject(Router);
const toast = inject(ToastService);
if (watchlist.count() > 0) return true;
toast.show('Add a movie to your watchlist first.');
return router.createUrlTree(['/browse']);
};
movie-detail.ts
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { MovieService } from '../../services/movie';
import { WatchlistService } from '../../services/watchlist';
import { MovieDetail as MovieDetailModel } from '../../models/movie.model';
import { posterUrl, backdropUrl } from '../../core/tmdb.config';
@Component({
selector: 'app-movie-detail',
standalone: true,
imports: [],
templateUrl: './movie-detail.html',
styleUrl: './movie-detail.css'
})
export class MovieDetail implements OnInit {
private route = inject(ActivatedRoute);
private movieService = inject(MovieService);
private watchlist = inject(WatchlistService);
movie?: MovieDetailModel;
loading = true;
get isInWatchlist(): boolean {
return this.movie ? this.watchlist.has(this.movie.id) : false;
}
get poster(): string {
return this.movie ? posterUrl(this.movie.poster_path, 'w342') : '';
}
get backdrop(): string {
return this.movie ? backdropUrl(this.movie.backdrop_path) : '';
}
ngOnInit(): void {
const id = +(this.route.snapshot.paramMap.get('id') ?? '0');
if (id) {
this.movieService.getDetail(id).subscribe({
next: movie => {
this.movie = movie;
this.loading = false;
},
error: () => { this.loading = false; }
});
}
}
toggle(): void {
if (this.movie) this.watchlist.toggle(this.movie as any);
}
}

movie-detail.html (abbreviated):

<div class="detail">
@if (loading) {
<p>Loading…</p>
} @else if (movie) {
@if (backdrop) {
<div class="backdrop" [style.background-image]="'url(' + backdrop + ')'"></div>
}
<div class="content">
<img [src]="poster" [alt]="movie.title" class="poster" />
<div class="info">
<h1>{{ movie.title }}</h1>
<p class="tagline">{{ movie.tagline }}</p>
<div class="meta">
<span>{{ movie.release_date | slice:0:4 }}</span>
<span>{{ movie.runtime }} min</span>
<span>★ {{ movie.vote_average | number:'1.1-1' }}</span>
</div>
<p class="overview">{{ movie.overview }}</p>
<button (click)="toggle()" [class.saved]="isInWatchlist">
{{ isInWatchlist ? '✓ In Watchlist' : '+ Add to Watchlist' }}
</button>
</div>
</div>
@if (movie.credits && movie.credits.cast.length > 0) {
<section class="cast">
<h2>Cast</h2>
<div class="cast-grid">
@for (member of movie.credits.cast | slice:0:12; track member.id) {
<div class="cast-member">
<img [src]="member.profile_path ? 'https://image.tmdb.org/t/p/w185' + member.profile_path : '/no-poster.svg'"
[alt]="member.name" />
<p class="name">{{ member.name }}</p>
<p class="character">{{ member.character }}</p>
</div>
}
</div>
</section>
}
}
</div>
  1. Implement WatchlistService, ToastService, and watchlistGuard as shown.
  2. Register the guard on the watchlist route in app.routes.ts.
  3. Implement MovieDetail with the backdrop, cast section, and watchlist toggle.
  4. Navigate to any movie detail page and confirm the cast grid appears.
  5. Try navigating to /watchlist with an empty watchlist — confirm you are redirected to /browse.
  6. Add a movie, then navigate to /watchlist — confirm you reach the page.
  • WatchlistService uses signal<Movie[]> for reactive state and localStorage for persistence.
  • ToastService uses a signal for the current toast and a timer for auto-dismissal.
  • watchlistGuard injects both services and redirects with a toast message when the watchlist is empty.
  • MovieDetail reads the :id route parameter, fetches with append_to_response=credits, and shows cast.
  • The credits property is optional in the model — guard with @if (movie.credits && ...) in the template.