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.
WatchlistService
Section titled “WatchlistService”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 []; } }}ToastService
Section titled “ToastService”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); }}The watchlistGuard
Section titled “The watchlistGuard”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']);};MovieDetail page
Section titled “MovieDetail page”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>Exercise
Section titled “Exercise”- Implement
WatchlistService,ToastService, andwatchlistGuardas shown. - Register the guard on the watchlist route in
app.routes.ts. - Implement
MovieDetailwith the backdrop, cast section, and watchlist toggle. - Navigate to any movie detail page and confirm the cast grid appears.
- Try navigating to
/watchlistwith an empty watchlist — confirm you are redirected to/browse. - Add a movie, then navigate to
/watchlist— confirm you reach the page.
WatchlistServiceusessignal<Movie[]>for reactive state andlocalStoragefor persistence.ToastServiceuses a signal for the current toast and a timer for auto-dismissal.watchlistGuardinjects both services and redirects with a toast message when the watchlist is empty.MovieDetailreads the:idroute parameter, fetches withappend_to_response=credits, and shows cast.- The
creditsproperty is optional in the model — guard with@if (movie.credits && ...)in the template.