Configuring the Front End
COMING SOON: A function-by-function explanation of the front-end code similar to what you just read for the backend.
Now move to the frontend folder.
First, make sure you have angular installed by typing:
npm install -g @angular/cli
Next, create an angular project. (This will create a new folder inside the frontend folder.)
ng new angular_front
This will create a complete skeleton application. Switch to the new folder:
cd angular_front
And let's add some components, a service, and an interface:
ng generate component add-media
ng generate component edit-media
ng generate component list-media
ng generate component query-builder
ng generate component show-media-details
ng generate service api
ng generate interface media
Add the Component Code
For each of the following components, move to the respective folder.
app.html and app.ts
First, open the app.html file and delete the entire contents (it's just starter code). Then replace it with the following:
<h1>Welcome to your Media Library</h1>
<nav>
<a routerLink="" class="action-button">Home</a>
<a routerLink="/query" class="action-button">Build a Query 🔍</a>
<a routerLink="/add" class="action-button">Add Media ➕</a>
</nav>
<router-outlet></router-outlet>
Then for app.ts (you shouldn't have to make any changes here, but here it is just in case):
import { Component, signal } from '@angular/core';
import { RouterOutlet, RouterLink } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink],
templateUrl: './app.html',
styleUrl: './app.css'
})
export class App {
protected readonly title = signal('angular_front');
}
styles.css
This is in the top folder next to index.html. (If it's not present, create it.) Add the following style code:
/* 1. Import the 'Inter' font from the Google Fonts API */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap');
/* 2. Apply the font to the entire document */
body {
font-family: 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/*
Base styles for all action buttons
*/
.action-button {
display: inline-block;
padding: 10px 20px;
margin-right: 10px;
border: none;
border-radius: 5px;
font-size: 1rem;
font-weight: bold;
color: white;
background-color: #007bff; /* A nice, standard blue */
text-align: center;
text-decoration: none; /* Removes underline from <a> tags */
cursor: pointer;
transition: background-color 0.2s ease-in-out, transform 0.1s ease;
}
/*
Hover effect for the primary button
*/
.action-button:hover {
background-color: #0056b3; /* A darker blue on hover */
}
/*
A subtle "press down" effect when clicking
*/
.action-button:active {
transform: translateY(1px);
}
/*
A secondary style for "Cancel" or less important buttons
*/
.action-button.secondary {
background-color: #6c757d; /* A neutral gray */
}
.action-button.secondary:hover {
background-color: #5a6268; /* A darker gray on hover */
}
/*
Style for when the button is disabled
*/
.action-button:disabled {
background-color: #cccccc;
cursor: not-allowed;
opacity: 0.7;
}
/*
A container to group the buttons and add some space
*/
.button-group {
margin-top: 20px;
border-top: 1px solid #eee;
padding-top: 20px;
}
nav {
margin:0.6em;
}
.container {
.list-item {
font-size:1.2em;
margin-bottom:0.4em;
margin-left:1em;
}
}
Add Media
Here's the code for the add media component.
add-media.ts:
import { Component, inject, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { Api } from '../api';
import { MediaItem } from '../media';
@Component({
selector: 'app-add-media',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './add-media.html',
styleUrl: './add-media.css'
})
export class AddMediaComponent implements OnInit {
private fb = inject(FormBuilder);
private apiService = inject(Api);
public addMediaForm: FormGroup;
public selectedType: 'Book' | 'Movie' | 'Music' = 'Book'; // Default selection
constructor() {
// Initialize the form with a type control
this.addMediaForm = this.fb.group({
type: ['Book', Validators.required]
});
}
ngOnInit() {
// Set the initial form fields for the default type
this.setFormFields(this.selectedType);
// Listen for changes to the type dropdown and swap the form fields
this.addMediaForm.get('type')?.valueChanges.subscribe(type => {
this.selectedType = type;
this.setFormFields(type);
});
}
// Dynamically sets the form controls based on the selected media type
setFormFields(type: 'Book' | 'Movie' | 'Music') {
// Clear existing controls (except for 'type') to start fresh
Object.keys(this.addMediaForm.controls).forEach(key => {
if (key !== 'type') {
this.addMediaForm.removeControl(key);
}
});
// Add common fields for all types
this.addMediaForm.addControl('title', this.fb.control('', Validators.required));
this.addMediaForm.addControl('description', this.fb.control(''));
this.addMediaForm.addControl('genre', this.fb.control(''));
this.addMediaForm.addControl('rating', this.fb.control(null, [Validators.min(0), Validators.max(10)]));
this.addMediaForm.addControl('year', this.fb.control(null, [Validators.min(1000), Validators.max(new Date().getFullYear())]));
// Add controls specific to the selected type
if (type === 'Book') {
this.addMediaForm.addControl('author', this.fb.control('', Validators.required));
this.addMediaForm.addControl('owned', this.fb.control(false));
} else if (type === 'Movie') {
this.addMediaForm.addControl('director', this.fb.control('', Validators.required));
this.addMediaForm.addControl('watched', this.fb.control(false));
} else if (type === 'Music') {
this.addMediaForm.addControl('artist', this.fb.control('', Validators.required));
this.addMediaForm.addControl('favorite', this.fb.control(false));
}
}
// Handles the form submission
saveMedia() {
if (this.addMediaForm.valid) {
const formValue = this.addMediaForm.value;
const mediaItem: MediaItem = {
...formValue,
type: formValue.type.toLowerCase()
}
console.log('Saving item:', mediaItem);
this.apiService.save(mediaItem).subscribe({
next: (response) => {
console.log('Save successful!', response);
// Optionally, reset the form or navigate away
this.addMediaForm.reset({ type: this.selectedType });
},
error: (err) => {
console.error('Save failed:', err);
}
});
} else {
console.error('Form is invalid.');
}
}
}
add-media.html:
<h2>Add New Media Item</h2>
<form [formGroup]="addMediaForm" (ngSubmit)="saveMedia()" class="add-media-container">
<div class="form-row">
<label for="type">Type:</label>
<select formControlName="type" id="type">
<option value="Book">Book</option>
<option value="Movie">Movie</option>
<option value="Music">Music</option>
</select>
</div>
@switch (selectedType) {
@case ('Book') {
<div class="form-row">
<label for="title">Title:</label>
<input id="title" formControlName="title">
</div>
<div class="form-row">
<label for="author">Author:</label>
<input id="author" formControlName="author">
</div>
}
@case ('Movie') {
<div class="form-row">
<label for="title">Title:</label>
<input id="title" formControlName="title">
</div>
<div class="form-row">
<label for="director">Director:</label>
<input id="director" formControlName="director">
</div>
}
@case ('Music') {
<div class="form-row">
<label for="title">Title:</label>
<input id="title" formControlName="title">
</div>
<div class="form-row">
<label for="artist">Artist:</label>
<input id="artist" formControlName="artist">
</div>
}
}
<div class="form-row">
<label for="description">Description:</label>
<textarea id="description" formControlName="description" rows="4"></textarea>
</div>
<div class="form-row">
<label for="genre">Genre:</label>
<input id="genre" formControlName="genre">
</div>
<div class="form-row">
<label for="rating">Rating (0-10):</label>
<input id="rating" type="number" formControlName="rating">
</div>
<div class="form-row">
<label for="year">Year:</label>
<input id="year" type="number" formControlName="year">
</div>
@switch (selectedType) {
@case ('Book') {
<div class="form-row-inline">
<input id="owned" type="checkbox" formControlName="owned">
<label for="owned">Owned</label>
</div>
}
@case ('Movie') {
<div class="form-row-inline">
<input id="watched" type="checkbox" formControlName="watched">
<label for="watched">Watched</label>
</div>
}
@case ('Music') {
<div class="form-row-inline">
<input id="favorite" type="checkbox" formControlName="favorite">
<label for="favorite">Favorite</label>
</div>
}
}
<button type="submit" class="action-button" [disabled]="!addMediaForm.valid">Save Media</button>
</form>
add-media.css:
.add-media-container {
max-width: 500px;
display: flex;
flex-direction: column;
gap: 15px;
}
.form-row {
display: flex;
flex-direction: column;
gap: 5px;
}
label {
font-weight: bold;
}
input, select {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
width: 100%;
}
.form-row-inline {
display: flex;
align-items: center;
gap: 10px;
}
.form-row-inline input {
width: auto; /* Let the checkbox take its natural size */
}
Edit Media
Here's the code for the edit media component.
edit-media.ts:
import { Component, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { Api } from '../api';
import { MediaItem, Book, Movie, Music } from '../media';
@Component({
selector: 'app-edit-media',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './edit-media.html',
styleUrl: './edit-media.css'
})
export class EditMediaComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private apiService = inject(Api);
private fb = inject(FormBuilder);
public mediaItem = signal<MediaItem | undefined>(undefined);
public editForm!: FormGroup;
ngOnInit() {
// Initialize with a placeholder form
this.editForm = this.fb.group({});
// Load data from state or API
if (history?.state?.data) {
this.setupFormForMedia(history.state.data);
} else {
const key = this.route.snapshot.paramMap.get('key');
if (key) {
this.apiService.getByKey(key).subscribe(item => {
this.setupFormForMedia(item as MediaItem);
});
}
}
}
setupFormForMedia(item: MediaItem) {
this.mediaItem.set(item);
// Common fields for all types
this.editForm = this.fb.group({
title: [item.title, Validators.required],
description: [item.description],
genre: [item.genre],
rating: [item.rating, [Validators.min(0), Validators.max(10)]],
year: [item.year, [Validators.min(1000), Validators.max(new Date().getFullYear())]],
});
// Add controls specific to the media type
switch (item.type) {
case 'book':
this.editForm.addControl('author', this.fb.control((item as Book).author, Validators.required));
this.editForm.addControl('owned', this.fb.control((item as Book).owned));
break;
case 'movie':
this.editForm.addControl('director', this.fb.control((item as Movie).director, Validators.required));
this.editForm.addControl('watched', this.fb.control((item as Movie).watched));
break;
case 'music':
this.editForm.addControl('artist', this.fb.control((item as Music).artist, Validators.required));
this.editForm.addControl('favorite', this.fb.control((item as Music).favorite));
break;
}
}
save() {
if (this.editForm.valid && this.mediaItem()) {
// Merge the original item (for key and type) with the form's values
const updatedItem = { ...this.mediaItem(), ...this.editForm.value };
console.log('Saving data:', updatedItem);
this.apiService.save(updatedItem).subscribe(() => {
this.navigateBack();
});
}
}
cancel() {
this.navigateBack();
}
private navigateBack() {
const item = this.mediaItem();
if (item) {
// Navigate back to the correct details view (e.g., '/book/key')
this.router.navigate([`/${item.type}`, item.key]);
}
}
}
edit-media.html:
@if (mediaItem(); as currentItem) {
<h2>Editing: {{ currentItem.title }}</h2>
<form [formGroup]="editForm" (ngSubmit)="save()">
<div class="details-grid">
<!-- Common Fields -->
<div class="grid-label">Title</div>
<input formControlName="title" />
<!-- Type-Specific Fields -->
@switch (currentItem.type) {
@case ('book') {
<div class="grid-label">Author</div>
<input formControlName="author" />
}
@case ('movie') {
<div class="grid-label">Director</div>
<input formControlName="director" />
}
@case ('music') {
<div class="grid-label">Artist</div>
<input formControlName="artist" />
}
}
<div class="grid-label">Description</div>
<textarea formControlName="description" rows="5"></textarea>
<div class="grid-label">Genre</div>
<input formControlName="genre" />
<div class="grid-label">Rating</div>
<input formControlName="rating" type="number" />
<div class="grid-label">Year</div>
<input formControlName="year" type="number" />
<!-- Type-Specific Boolean Field -->
@switch (currentItem.type) {
@case ('book') {
<div class="grid-label">Owned</div>
<input formControlName="owned" type="checkbox" />
}
@case ('movie') {
<div class="grid-label">Watched</div>
<input formControlName="watched" type="checkbox" />
}
@case ('music') {
<div class="grid-label">Favorite</div>
<input formControlName="favorite" type="checkbox" />
}
}
</div>
<div class="button-group">
<button type="button" class="action-button secondary" (click)="cancel()">Cancel</button>
<button type="submit" class="action-button" [disabled]="!editForm.valid">Save Changes</button>
</div>
</form>
} @else {
<div>Loading editor...</div>
}
edit-media.css: We don't add any code here; leave it empty
List Media
Here's the code for the edit media component.
list-media.ts:
import { Component, signal } from '@angular/core';
import { Api } from '../api';
import { MediaItem } from '../media';
import { isNgContainer } from '@angular/compiler';
import { RouterLink } from '@angular/router';
import { ShowMediaDetailsComponent } from '../show-media-details/show-media-details';
@Component({
selector: 'app-list-media',
imports: [ RouterLink ],
templateUrl: './list-media.html',
styleUrl: './list-media.css'
})
export class ListMedia {
public demoDataPresent = false;
public mediaList = signal<MediaItem[]>([]);
constructor(private apiService: Api) {}
ngOnInit(): void {
this.loadMedia()
}
loadDemoData() {
this.apiService.loadDemoDataIntoGolem().subscribe({
next:(str) => {
// After demo data is loaded into Golem, try again
this.loadMedia();
}
});
}
loadMedia() {
this.apiService.getAll().subscribe({
next: (data) => {
this.mediaList.set(data);
console.log('Demo data:')
console.log(data);
this.demoDataPresent = data && data.length > 0;
},
error: (err) => { // todo - display a friendly error on the page
console.log('Error loading media: ', err)
}
})
}
purgeData() {
this.apiService.purge().subscribe({
next: (data) => {
this.loadMedia();
}
})
}
}
{% raw %}
list-media.html:
<div class="container">
@if (!demoDataPresent) {
<div>
Demo data not present.
<button (click)="loadDemoData()">Load Demo Data</button>
</div>
}
@for (mediaItem of mediaList(); track mediaItem.key) {
<div class="list-item">
<a [routerLink]="['/', mediaItem.type, mediaItem.key]" [state]="{ data: mediaItem }">
{{ mediaItem.title }}
</a>
@switch (mediaItem.type) {
@case ('book') {
<span class="creator-info"> (book by {{ mediaItem.author }})</span>
}
@case ('movie') {
<span class="creator-info"> (movie by {{ mediaItem.director }})</span>
}
@case ('music') {
<span class="creator-info"> (music by {{ mediaItem.artist }})</span>
}
}
</div>
}
@if (demoDataPresent) {
<div>
<button (click)="purgeData()">Purge Data</button>
</div>
}
</div>
list-media.css: We don't add anything to this file; leave it blank.
Query Builder
Here's the code for the edit media component.
query-builder.ts:
import { CommonModule } from '@angular/common';
import { Component, inject, signal } from '@angular/core';
import { AbstractControl, FormArray, FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MediaItem, SearchOptions } from '../media';
import { Api } from '../api';
import { ShowMediaDetailsComponent } from '../show-media-details/show-media-details';
@Component({
selector: 'app-query-builder',
imports: [CommonModule, ReactiveFormsModule, ShowMediaDetailsComponent],
templateUrl: './query-builder.html',
styleUrl: './query-builder.css'
})
export class QueryBuilder {
private apiService = inject(Api);
private fb = inject(FormBuilder);
public searchOptions = signal<SearchOptions | null>(null);
public queryForm: FormGroup;
//public queryResult = signal<any | null>(null);
public queryResults = signal<MediaItem[] | null>(null);
constructor() {
// Initialize the form structure
this.queryForm = this.fb.group({
type: ['Movie'], // Default type
criteria: this.fb.array([])
});
}
ngOnInit() {
// Fetch options when the component loads
this.apiService.getSearchOptions().subscribe(options => {
this.searchOptions.set(options);
});
// Listen for changes to the main 'type' dropdown
this.queryForm.get('type')?.valueChanges.subscribe(() => {
if (this.criteria.length > 0) {
if (window.confirm('This will remove the rest of the query. Are you sure?')) {
this.criteria.clear();
}
}
});
}
// Getter for easy access to the criteria FormArray in the template
get criteria(): FormArray {
return this.queryForm.get('criteria') as FormArray;
}
// Adds a new, empty criterion row to the form
addCriterion() {
const criterionGroup = this.fb.group({
field: [''],
value: ['']
});
this.criteria.push(criterionGroup);
}
// Removes a criterion row by its index
removeCriterion(index: number) {
this.criteria.removeAt(index);
}
// Dynamically get the field options based on the selected media type
getFieldOptions(): string[] {
const type = this.queryForm.get('type')?.value;
switch (type) {
case 'Movie': return ['Director', 'Genre', 'Year'];
case 'Book': return ['Author', 'Genre', 'Year'];
case 'Music': return ['Artist', 'Genre', 'Year'];
default: return [];
}
}
// Dynamically get the value options based on the selected field
getValueOptions(criterionGroup: AbstractControl): string[] {
const type = this.queryForm.get('type')?.value;
const field = criterionGroup.get('field')?.value;
const options = this.searchOptions();
if (!options) return [];
switch (field) {
case 'Director': return options.directors;
case 'Author': return options.authors;
case 'Artist': return options.artists;
case 'Genre':
if (type === 'Movie') return options.movie_genres;
if (type === 'Book') return options.book_genres;
if (type === 'Music') return options.music_genres;
return [];
default: return [];
}
}
// Builds and logs the final query string when "Go" is clicked
executeQuery() {
const formValue = this.queryForm.value;
let queryString = `type=${formValue.type}`.toLowerCase();
formValue.criteria.forEach((crit: { field: string, value: string }) => {
if (crit.field && crit.value) {
const fieldKey = crit.field.toLowerCase();
queryString += `&${fieldKey}=${crit.value}`;
}
});
console.log('Executing Query:', queryString);
this.apiService.executeQuery(queryString).subscribe({
next: (results) => {
console.log('API Response from /search-options:', results);
this.queryResults.set(results);
},
error: (err) => {
console.error('Error fetching search options:', err);
}
});
}
}
query-builder.html:
<h2>Query Builder</h2>
<form [formGroup]="queryForm" (ngSubmit)="executeQuery()" class="query-container">
<div class="query-row">
<select formControlName="type" class="query-select">
<option>Movie</option>
<option>Book</option>
<option>Music</option>
</select>
</div>
<div formArrayName="criteria">
@for (criterion of criteria.controls; track $index) {
<div [formGroupName]="$index" class="query-row">
<select formControlName="field" class="query-select">
<option value="">-- Select Field --</option>
@for (field of getFieldOptions(); track field) {
<option [value]="field">{{ field }}</option>
}
</select>
<span class="operator">=</span>
@if (criterion.value.field === 'Year') {
<input type="number" formControlName="value" placeholder="Enter Year" class="query-input">
} @else {
<select formControlName="value" class="query-select">
<option value="">-- Select Value --</option>
@for (value of getValueOptions(criterion); track value) {
<option [value]="value">{{ value }}</option>
}
</select>
}
<button type="button" (click)="removeCriterion($index)" class="delete-btn">❌</button>
</div>
}
</div>
<div class="action-buttons">
<button type="button" class="action-btn" (click)="addCriterion()">And</button>
<button type="submit" class="action-btn go-btn">Go</button>
</div>
</form>
<div class="results-container">
@if (queryResults(); as results) {
<h2>{{ results.length }} Results Found</h2>
<div class="results-list">
@for (item of results; track item.key) {
<app-show-media-details [itemInput]="item" />
}
</div>
}
</div>
query-builder.css:
.query-container {
display: flex;
flex-direction: column;
gap: 15px;
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
background-color: #f9f9f9;
}
.query-row {
display: flex;
align-items: center;
gap: 10px;
}
.query-select, .query-input {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
.operator {
font-weight: bold;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 5px;
width: 60px; /* Fixed width for the stacked buttons */
}
.action-btn {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.go-btn {
background-color: #28a745;
color: white;
border-color: #28a745;
}
.delete-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.2rem;
}
.results-area {
margin-top: 25px;
padding-top: 20px;
border-top: 2px solid #e0e0e0;
}
pre {
background-color: #2b2b2b; /* Dark background for the code block */
color: #a9b7c6; /* Light text color */
padding: 15px;
border-radius: 5px;
white-space: pre-wrap; /* Ensures long lines wrap */
word-break: break-all;
}
Show Media Details
Here's the code for the edit media component.
show-media-details.ts:
import { Component, effect, inject, input, OnInit, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { CommonModule } from '@angular/common';
import { Api } from '../api';
import { MediaItem } from '../media';
@Component({
selector: 'app-show-media-details',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './show-media-details.html',
styleUrl: './show-media-details.css'
})
export class ShowMediaDetailsComponent implements OnInit {
private route = inject(ActivatedRoute);
private apiService = inject(Api);
// 1. Add a new optional input to receive data directly
public itemInput = input<MediaItem | undefined>(undefined);
// 2. The internal signal that the template will use
public mediaItem = signal<MediaItem | undefined>(undefined);
constructor() {
// 3. Use an effect to automatically update the internal signal when the input changes
effect(() => {
if (this.itemInput()) {
this.mediaItem.set(this.itemInput());
}
});
}
ngOnInit() {
// 4. Only check the route/state if no data was passed via input
if (!this.itemInput()) {
if (history?.state?.data) {
this.mediaItem.set(history.state.data);
} else {
const key = this.route.snapshot.paramMap.get('key');
if (key) {
this.apiService.getByKey(key).subscribe(itemValue => {
this.mediaItem.set(itemValue as MediaItem);
});
}
}
}
}
}
show-media-details.html:
{% raw %}
@if (mediaItem(); as item) {
<h2>{{ item.type | titlecase }} Details</h2>
<table>
<tbody>
<tr>
<td>Title</td>
<td>{{ item.title }}</td>
</tr>
@switch (item.type) {
@case ('book') { <tr><td>Author</td><td>{{ item.author }}</td></tr> }
@case ('movie') { <tr><td>Director</td><td>{{ item.director }}</td></tr> }
@case ('music') { <tr><td>Artist</td><td>{{ item.artist }}</td></tr> }
}
<tr>
<td>Description</td>
<td>{{ item.description }}</td>
</tr>
<tr>
<td>Genre</td>
<td>{{ item.genre }}</td>
</tr>
<tr>
<td>Rating</td>
<td>{{ item.rating }} / 10</td>
</tr>
<tr>
<td>Year</td>
<td>{{ item.year }}</td>
</tr>
@switch (item.type) {
@case ('book') { <tr><td>Owned</td><td>{{ item.owned ? 'Yes' : 'No' }}</td></tr> }
@case ('movie') { <tr><td>Watched</td><td>{{ item.watched ? 'Yes' : 'No' }}</td></tr> }
@case ('music') { <tr><td>Favorite</td><td>{{ item.favorite ? 'Yes' : 'No' }}</td></tr> }
}
</tbody>
</table>
<a [routerLink]="['/', item.type, item.key, 'edit']" [state]="{ data: item }" class="action-button">
Edit 📝
</a>
} @else {
<div>Loading...</div>
}
show-media-details.css: We don't add any new CSS here; leave it empty.
Next we'll add the code for the service and interface.
Head to Step 6.