Can you give me step-by step plan how can i create from scratch Angular 20 app using standalone components and material design to show the poem from the list of poems provided by REST API.
REST API
We have REST API for the poems:
| Method | Endpoint | Description |
|---|---|---|
GET |
|
List all available poem files |
GET |
|
Get poem count for a specific file |
GET |
|
Get a specific poem by number |
Here is the example of JSON returned by /poems/{filename}/{number}:
{
"title": "От автора",
"text": "К автору этих строк \nвек был не слишком строг: \nв общем, не бил по рукам… по строкам \nавтора этих строк. \nВпрочем, и автор сам \nих не ему писал — \nсобственно, он их писал облакам, \nсобственно, небесам. \n \nНету таких веков, \nнету таких оков, \nи не бывает таких дураков — \nвзять да и променять \nна, например, альков \nшествие облаков — \nвсех этих белых коней без подков \nмедленный променад. \n \nЯ бы, наверно, мог \nувековечить век — \nили хотя бы стать с ним наравне, \nесли б я только мог, \nесли бы я привык \nчислить себя в живых, \nесли б я был человеком, а не \nавтором этих строк."
}
Poem text is returned as markdown.
UI
App should store in local storage these variables describing the poem that was displayed last time:
-
the name of poem file
-
the number of the poem
-
date in YYYY-MM-DD format.
When the app is opened, it should display the poem. If current date is different from the stored date, then it should display the next poem, else the poem should be the same.
After the last poem in the list we should display the first one.
In the header we should have poem title and a menu.
Menu should have items:
-
Book
-
Random
"Book" item should open a dialog to select the name of the poem file. "Random" should go to the random poem in the book.
In the footer the app should have 2 equally sized buttons to go the previous or next poem:
-
Prev
-
Next
Font
App should use "Great Vibes" Google font to display the poem.
Embedding code for this font:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Great+Vibes&display=swap" rel="stylesheet">
CSS class for this font:
.great-vibes-regular {
font-family: "Great Vibes", cursive;
font-weight: 400;
font-style: normal;
}
Here’s a step-by-step plan to create your Angular 20 poetry app from scratch using standalone components and Angular Material.
Step 1: Project Setup
First, set up your Angular project, install Angular Material, and add the required Google Font.
-
Install Angular CLI (if you don’t have it):
npm install -g @angular/cli -
Create a New Angular App: This command creates a new project named
poem-appconfigured for standalone components and SCSS for styling.ng new poem-app --standalone --style=scss cd poem-app -
Add Angular Material: This will automatically configure Material Design components for your project.
ng add @angular/materialFollow the prompts, selecting a theme of your choice.
-
Add Google Font: Open
src/index.htmland add the font links inside the<head>tag.<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Great+Vibes&display=swap" rel="stylesheet"> -
Add Font CSS: Open
src/styles.scssand add the provided CSS class..great-vibes-regular { font-family: "Great Vibes", cursive; font-weight: 400; font-style: normal; } -
Install Markdown Renderer: We’ll use
ngx-markdownto render the poem text.npm install ngx-markdown
Step 2: Configure API and Services
Create services to handle API calls and manage the application’s state.
ng generate environments
-
Configure Environment: It’s good practice to store your API URL in the environment files. Open
src/environments/environment.tsand add your API’s base URL.export const environment = { production: false, apiBaseUrl: 'https://your-api-goes-here.com/api' // Replace with your actual API URL }; -
Create Data Interfaces: Create a file
src/app/poem.model.tsto define the shape of your data.export interface Poem { title: string; text: string; } export interface PoemState { filename: string; number: number; date: string; // YYYY-MM-DD } -
Create Poem Service: This service will fetch data from the REST API.
ng generate service services/poem.serviceUpdate
src/app/services/poem.service.ts:import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { environment } from '../../environments/environment'; import { Poem } from '../poem.model'; @Injectable({ providedIn: 'root' }) export class PoemService { private http = inject(HttpClient); private apiUrl = `${environment.apiBaseUrl}/poems`; getPoemFiles(): Observable<string[]> { return this.http.get<string[]>(this.apiUrl); } getPoemCount(filename: string): Observable<{ count: number }> { return this.http.get<{ count: number }>(`${this.apiUrl}/${filename}`); } getPoem(filename: string, number: number): Observable<Poem> { return this.http.get<Poem>(`${this.apiUrl}/${filename}/${number}`); } } -
Create State Management Service: This service will manage
localStorageand the current poem state.ng generate service services/state.serviceUpdate
src/app/services/state.service.ts. This service will contain the core logic for poem navigation.import { Injectable, inject } from '@angular/core'; import { BehaviorSubject, switchMap, tap, take } from 'rxjs'; import { Poem, PoemState } from '../poem.model'; import { PoemService } from './poem.service'; const STATE_KEY = 'poemAppState'; @Injectable({ providedIn: 'root' }) export class StateService { private poemService = inject(PoemService); // Default state if nothing is in local storage private defaultState: PoemState = { filename: 'first-book.txt', number: 1, date: this.getTodayDate() }; // Observables for components to subscribe to public poemState$ = new BehaviorSubject<PoemState>(this.loadState()); public currentPoem$ = this.poemState$.pipe( switchMap(state => this.poemService.getPoem(state.filename, state.number)) ); constructor() { this.initialize(); } private initialize() { const state = this.poemState$.getValue(); if (state.date !== this.getTodayDate()) { this.navigateToNextPoem(); } } private getTodayDate(): string { return new Date().toISOString().split('T')[0]; } private loadState(): PoemState { const storedState = localStorage.getItem(STATE_KEY); return storedState ? JSON.parse(storedState) : this.defaultState; } private saveState(state: PoemState) { localStorage.setItem(STATE_KEY, JSON.stringify(state)); this.poemState$.next(state); } navigateToNextPoem() { this.poemService.getPoemCount(this.poemState$.getValue().filename).pipe(take(1)).subscribe(res => { const currentState = this.poemState$.getValue(); let nextNumber = currentState.number + 1; if (nextNumber > res.count) { nextNumber = 1; // Loop back to the first poem } this.saveState({ ...currentState, number: nextNumber, date: this.getTodayDate() }); }); } navigateToPrevPoem() { this.poemService.getPoemCount(this.poemState$.getValue().filename).pipe(take(1)).subscribe(res => { const currentState = this.poemState$.getValue(); let prevNumber = currentState.number - 1; if (prevNumber < 1) { prevNumber = res.count; // Loop back to the last poem } this.saveState({ ...currentState, number: prevNumber, date: this.getTodayDate() }); }); } navigateToRandomPoem() { this.poemService.getPoemCount(this.poemState$.getValue().filename).pipe(take(1)).subscribe(res => { const currentState = this.poemState$.getValue(); const randomNumber = Math.floor(Math.random() * res.count) + 1; this.saveState({ ...currentState, number: randomNumber, date: this.getTodayDate() }); }); } changeBook(newFilename: string) { this.saveState({ filename: newFilename, number: 1, date: this.getTodayDate() }); } }
Step 3: Global Configuration
Now, update your main application configuration to provide the necessary services and modules globally.
Open src/app/app.config.ts and update it:
import { ApplicationConfig, importProvidersFrom } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { HttpClientModule } from '@angular/common/http';
import { MarkdownModule } from 'ngx-markdown';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideAnimationsAsync(),
importProvidersFrom(
HttpClientModule,
MarkdownModule.forRoot() // Provide ngx-markdown
)
]
};
Step 4: Build Components
Create the UI components for the header, footer, poem view, and book selection dialog.
-
Main App Component Layout: This will serve as the main container. Open
src/app/app.component.htmland replace its content:<div class="app-container"> <app-header></app-header> <main> <router-outlet></router-outlet> </main> <app-footer></app-footer> </div>Add styles to
src/app/app.component.scss::host { display: block; height: 100vh; } .app-container { display: flex; flex-direction: column; height: 100%; } main { flex: 1; overflow-y: auto; padding: 24px; } -
Poem View Component: This will display the poem.
ng generate component components/poem-viewUpdate
src/app/components/poem-view/poem-view.ts:import { Component, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MarkdownModule } from 'ngx-markdown'; import { StateService } from '../../services/state.service'; @Component({ selector: 'app-poem-view', standalone: true, imports: [CommonModule, MarkdownModule], template: ` @if (poem$ | async; as poem) { <div class="poem-text great-vibes-regular"> <markdown [data]="poem.text"></markdown> </div> } @else { <p>Loading poem...</p> } `, styles: ` .poem-text { white-space: pre-wrap; /* Respects newlines in the text */ font-size: 1.8rem; line-height: 1.6; text-align: center; } ` }) export class PoemView { private stateService = inject(StateService); poem$ = this.stateService.currentPoem$; } -
Header Component:
ng generate component components/headerUpdate
src/app/components/header/header.ts:import { Component, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { MatDialog } from '@angular/material/dialog'; import { StateService } from '../../services/state.service'; import { BookDialog } from '../book-dialog/book-dialog'; @Component({ selector: 'app-header', standalone: true, imports: [CommonModule, MatToolbarModule, MatButtonModule, MatIconModule, MatMenuModule], templateUrl: './header.html', styleUrl: './header.css' }) export class Header { private stateService = inject(StateService); private dialog = inject(MatDialog); poem$ = this.stateService.currentPoem$; openBookSelection() { const dialogRef = this.dialog.open(BookDialogComponent); dialogRef.afterClosed().subscribe(result => { if (result) { this.stateService.changeBook(result); } }); } selectRandom() { this.stateService.navigateToRandomPoem(); } }Update
src/app/components/header/header.html:<mat-toolbar color="primary"> @if(poem$ | async; as poem) { <span>{{ poem.title }}</span> } <span class="spacer"></span> <button mat-icon-button [matMenuTriggerFor]="menu"> <mat-icon>more_vert</mat-icon> </button> <mat-menu #menu="matMenu"> <button mat-menu-item (click)="openBookSelection()"> <mat-icon>book</mat-icon> <span>Book</span> </button> <button mat-menu-item (click)="selectRandom()"> <mat-icon>shuffle</mat-icon> <span>Random</span> </button> </mat-menu> </mat-toolbar>Update
src/app/components/header/header.css:.spacer { flex: 1 1 auto; } -
Footer Component:
ng generate component components/footerUpdate
src/app/components/footer/footer.component.ts:import { Component, inject } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { StateService } from '../../services/state.service'; @Component({ selector: 'app-footer', standalone: true, imports: [MatButtonModule], template: ` <div class="footer-nav"> <button mat-flat-button color="primary" (click)="prev()">Prev</button> <button mat-flat-button color="primary" (click)="next()">Next</button> </div> `, styles: ` .footer-nav { display: flex; gap: 8px; padding: 8px; border-top: 1px solid #ccc; > button { flex: 1; } } ` }) export class Footer { private stateService = inject(StateService); prev() { this.stateService.navigateToPrevPoem(); } next() { this.stateService.navigateToNextPoem(); } } -
Book Selection Dialog Component:
ng generate component components/book-dialogUpdate
src/app/components/book-dialog/book-dialog.component.ts:import { Component, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatListModule } from '@angular/material/list'; import { MatButtonModule } from '@angular/material/button'; import { PoemService } from '../../services/poem.service'; @Component({ selector: 'app-book-dialog', standalone: true, imports: [CommonModule, MatDialogModule, MatListModule, MatButtonModule], template: ` <h2 mat-dialog-title>Select a Book</h2> <mat-dialog-content> @if(books$ | async; as books) { <mat-list> @for(book of books; track book) { <mat-list-item (click)="selectBook(book)">{{ book }}</mat-list-item> } </mat-list> } </mat-dialog-content> <mat-dialog-actions> <button mat-button [mat-dialog-close]>Cancel</button> </mat-dialog-actions> ` }) export class BookDialog { private poemService = inject(PoemService); public dialogRef = inject(MatDialogRef<BookDialog>); books$ = this.poemService.getPoemFiles(); selectBook(filename: string) { this.dialogRef.close(filename); } }
Step 5: Routing and Final Assembly
Finally, wire up the routing and update the main app component to bring everything together.
-
Set Up Routes: Open
src/app/app.routes.tsand define the route to your poem viewer.import { Routes } from '@angular/router'; import { PoemViewComponent } from './components/poem-view/poem-view.component'; export const routes: Routes = [ { path: '', component: PoemViewComponent, pathMatch: 'full' }, { path: '**', redirectTo: '', pathMatch: 'full' } ]; -
Assemble the App Component: Open
src/app/app.component.ts. Ensure it’s standalone and imports all the child components and the router outlet.import { Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { HeaderComponent } from './components/header/header.component'; import { FooterComponent } from './components/footer/footer.component'; @Component({ selector: 'app-root', standalone: true, imports: [RouterOutlet, HeaderComponent, FooterComponent], templateUrl: './app.component.html', styleUrl: './app.component.scss' }) export class AppComponent { title = 'poem-app'; }
You can now run your application:
ng serve
Navigate to http://localhost:4200 to see your poetry app in action.
✨