Add bookmarks

This commit is contained in:
Patrick Gebhardt 2020-06-19 17:25:05 +02:00
parent 587e231e05
commit 9c3f70d36b
19 changed files with 301 additions and 21 deletions

View File

@ -4,12 +4,14 @@ import {HomeComponent} from './containers/home/home.component';
import {NotfoundComponent} from './containers/notfound/notfound.component';
import {SearchComponent} from './containers/search/search.component';
import {RegionDetailsComponent} from './containers/region-details/region-details.component';
import {BookmarkListComponent} from './containers/bookmark-list/bookmark-list.component';
const routes: Routes = [
{path: 'home', component: HomeComponent},
{path: 'search', component: SearchComponent},
{path: 'region/:id', component: RegionDetailsComponent},
{path: 'bookmark', component: BookmarkListComponent},
{path: '', redirectTo: 'home', pathMatch: 'full'},
{path: '**', component: NotfoundComponent}
];

View File

@ -9,10 +9,16 @@
<div class="nav">
<a mat-button routerLink="home" (click)="drawer.close()">
<mat-icon>home</mat-icon>
<span>Home</span></a>
<span>Home</span>
</a>
<a (click)="drawer.close()" mat-button routerLink="bookmark">
<mat-icon>bookmark</mat-icon>
<span>Your bookmarks</span>
</a>
<a mat-button routerLink="impressum" (click)="drawer.close()">
<mat-icon>subject</mat-icon>
<span>Impressum</span></a>
<span>Impressum</span>
</a>
</div>
</mat-drawer>
<div class="routed-component-wrapper">

View File

@ -21,13 +21,15 @@ import {TranslateModule, TranslateService} from '@ngx-translate/core';
// @ts-ignore
import * as enLang from '../assets/i18n/en.json';
import {HttpClientModule} from '@angular/common/http';
import {MatButtonToggleModule, MatCheckboxModule, MatDividerModule} from '@angular/material';
import {MatButtonToggleModule, MatCheckboxModule, MatDividerModule, MatTooltipModule} from '@angular/material';
import {FormsModule} from '@angular/forms';
import {RegionComponent} from './components/region/region.component';
import {ResultComponent} from './components/result/result.component';
import {RegionDetailsComponent} from './containers/region-details/region-details.component';
import {GraphComponent} from './components/graph/graph.component';
import {RegionStatsComponent} from './components/region-stats/region-stats.component';
import {BookmarkButtonComponent} from './components/bookmark-button/bookmark-button.component';
import {BookmarkListComponent} from './containers/bookmark-list/bookmark-list.component';
@NgModule({
@ -41,7 +43,9 @@ import {RegionStatsComponent} from './components/region-stats/region-stats.compo
ResultComponent,
RegionDetailsComponent,
GraphComponent,
RegionStatsComponent
RegionStatsComponent,
BookmarkButtonComponent,
BookmarkListComponent
],
imports: [
BrowserModule,
@ -61,7 +65,8 @@ import {RegionStatsComponent} from './components/region-stats/region-stats.compo
MatCheckboxModule,
FormsModule,
MatButtonToggleModule,
MatDividerModule
MatDividerModule,
MatTooltipModule
],
providers: [],
bootstrap: [AppComponent]

View File

@ -0,0 +1,7 @@
<button (click)="onToggle($event)"
[color]="isBookmarked ? 'accent' : 'primary'"
mat-icon-button
matTooltip="{{isBookmarked ? 'Remove from bookmarks' : 'Add to bookmarks'}}"
>
<mat-icon>{{isBookmarked ? 'bookmark' : 'bookmark_border'}}</mat-icon>
</button>

View File

@ -0,0 +1,25 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {BookmarkButtonComponent} from './bookmark-button.component';
describe('BookmarkButtonComponent', () => {
let component: BookmarkButtonComponent;
let fixture: ComponentFixture<BookmarkButtonComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [BookmarkButtonComponent]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BookmarkButtonComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,40 @@
import {Component, Input, OnDestroy, OnInit} from '@angular/core';
import {Region} from '../../interfaces/region.interface';
import {BookmarkService} from '../../services/bookmark.service';
import {Subject} from 'rxjs';
import {takeUntil} from 'rxjs/operators';
@Component({
selector: 'app-bookmark-button',
templateUrl: './bookmark-button.component.html',
styleUrls: ['./bookmark-button.component.scss']
})
export class BookmarkButtonComponent implements OnInit, OnDestroy {
@Input()
region: Region;
isBookmarked = false;
private destroyed$ = new Subject<void>();
constructor(private bs: BookmarkService) {
}
ngOnInit() {
this.bs.isMarked(this.region.region_id).pipe(
takeUntil(this.destroyed$)
).subscribe(val => this.isBookmarked = val);
}
onToggle(event: Event) {
event.stopPropagation();
this.bs.toggleRegion(this.region.region_id);
}
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
}
}

View File

@ -1,13 +1,12 @@
<div class="region-mat-card">
<img class="region-img" src="https://travopti.de/api/v1/regions/{{region.region_id}}/image">
<img alt="Picture of {{region.name}}" class="region-img"
src="https://travopti.de/api/v1/regions/{{region.region_id}}/image">
<div class="region-footer">
<div class="region-title">
<span class="region-name">{{region.name}}</span>
<span class="region-country">| {{region.country}}</span>
</div>
<button mat-icon-button>
<mat-icon>bookmark</mat-icon>
</button>
<app-bookmark-button [region]="region"></app-bookmark-button>
<button mat-icon-button>
<mat-icon>share</mat-icon>
</button>

View File

@ -1,13 +1,12 @@
<div class="result-mat-card">
<img class="result-img" src="https://travopti.de/api/v1/regions/{{result.region_id}}/image">
<img alt="Picture of {{result.name}}" class="result-img"
src="https://travopti.de/api/v1/regions/{{result.region_id}}/image">
<div class="result-title">
<div class="result-name">
<span class="result-name">{{result.name}}</span>
<span class="result-country">| {{result.country}}</span>
</div>
<button mat-icon-button>
<mat-icon>bookmark</mat-icon>
</button>
<app-bookmark-button [region]="result"></app-bookmark-button>
<button mat-icon-button>
<mat-icon>share</mat-icon>
</button>

View File

@ -0,0 +1,10 @@
<h2>Your bookmarks</h2>
<div *ngIf="!isLoading" class="bookmarks-contianer">
<app-region (click)="onBookmarkClick(bookmark)" *ngFor="let bookmark of bookmarks" [region]="bookmark"></app-region>
<span *ngIf="bookmarks.length === 0">You have no bookmarks :(</span>
</div>
<div *ngIf="isLoading" class="spinner">
<mat-spinner></mat-spinner>
</div>

View File

@ -0,0 +1,23 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
}
.bookmarks-contianer {
display: flex;
flex-direction: column;
justify-content: center;
> * {
margin-bottom: 2rem;
}
}
.spinner {
flex: 1 1 auto;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}

View File

@ -0,0 +1,25 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {BookmarkListComponent} from './bookmark-list.component';
describe('BookmarkListComponent', () => {
let component: BookmarkListComponent;
let fixture: ComponentFixture<BookmarkListComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [BookmarkListComponent]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BookmarkListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,46 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {Region} from '../../interfaces/region.interface';
import {BookmarkService} from '../../services/bookmark.service';
import {DataService} from '../../services/data.service';
import {switchMap, takeUntil, tap} from 'rxjs/operators';
import {Router} from '@angular/router';
import {Subject} from 'rxjs';
@Component({
selector: 'app-bookmark-list',
templateUrl: './bookmark-list.component.html',
styleUrls: ['./bookmark-list.component.scss']
})
export class BookmarkListComponent implements OnInit, OnDestroy {
bookmarks: Region[];
isLoading = true;
private destroyed$ = new Subject<void>();
constructor(private bs: BookmarkService, private ds: DataService, private router: Router) {
}
ngOnInit() {
this.bs.getRegionIds().pipe(
takeUntil(this.destroyed$),
tap(() => this.isLoading = true),
switchMap(ids => {
const regions: Promise<Region>[] = ids.map(id => this.ds.getRegion(id));
return Promise.all(regions);
})
).subscribe(regions => {
this.bookmarks = regions;
this.isLoading = false;
});
}
onBookmarkClick(region: Region) {
this.router.navigate(['/region', region.region_id]).catch(console.log);
}
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
}
}

View File

@ -1,13 +1,12 @@
<div *ngIf="region">
<img class="region-img" src="https://travopti.de/api/v1/regions/{{region.region_id}}/image">
<img alt="Picture of {{region.name}}" class="region-img"
src="https://travopti.de/api/v1/regions/{{region.region_id}}/image">
<div class="region-details-header">
<div class="region-title">
<span class="region-country">{{region.country}}</span>
<span class="region-name">{{region.name}}</span>
</div>
<button mat-icon-button>
<mat-icon>bookmark</mat-icon>
</button>
<app-bookmark-button [region]="region"></app-bookmark-button>
<button mat-icon-button>
<mat-icon>share</mat-icon>
</button>

View File

@ -0,0 +1,12 @@
import {TestBed} from '@angular/core/testing';
import {BookmarkService} from './bookmark.service';
describe('BookmarkService', () => {
beforeEach(() => TestBed.configureTestingModule({}));
it('should be created', () => {
const service: BookmarkService = TestBed.get(BookmarkService);
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,63 @@
import {Injectable} from '@angular/core';
import {BehaviorSubject, Observable} from 'rxjs';
import {base64ToObj, objToBase64} from '../utils/base64conversion';
import {map} from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class BookmarkService {
private readonly LOCAL_STORAGE_KEY = 'bookmarks';
private readonly storage: Storage;
private readonly regionIds$: BehaviorSubject<number[]> = new BehaviorSubject<number[]>([]);
constructor() {
this.storage = localStorage;
this.load();
}
public addRegion(regionId: number) {
const regions = this.regionIds$.getValue();
regions.push(regionId);
this.regionIds$.next(regions);
this.save();
}
public removeRegion(regionId: number) {
let regions = this.regionIds$.getValue();
regions = regions.filter(id => id !== regionId);
this.regionIds$.next(regions);
this.save();
}
public toggleRegion(regionId: number) {
if (this.regionIds$.getValue().includes(regionId)) {
this.removeRegion(regionId);
} else {
this.addRegion(regionId);
}
}
public getRegionIds(): BehaviorSubject<number[]> {
return this.regionIds$;
}
public isMarked(regionId: number): Observable<boolean> {
return this.regionIds$.pipe(
map(ids => ids.includes(regionId))
);
}
private load() {
const regionIds = base64ToObj(this.storage.getItem(this.LOCAL_STORAGE_KEY)) as number[];
this.regionIds$.next(regionIds || []);
}
private save() {
this.storage.setItem(this.LOCAL_STORAGE_KEY, objToBase64(this.regionIds$.getValue()));
}
}

View File

@ -11,6 +11,8 @@ export class DataService {
private readonly API_URL = 'https://travopti.de/api/v1';
private readonly regionCache = new Map<number, Region>();
constructor(private http: HttpClient) {
}
@ -34,8 +36,12 @@ export class DataService {
// });
}
public getAllRegions(): Promise<Region[]> {
return this.http.get<Region[]>(this.API_URL + '/regions').toPromise();
public async getAllRegions(): Promise<Region[]> {
const regions = await this.http.get<Region[]>(this.API_URL + '/regions').toPromise();
regions.forEach(region => this.regionCache.set(region.region_id, region));
return regions;
// return new Promise<Region[]>(resolve => {
// setTimeout(() => {
// resolve(MOCK_REGIONS);
@ -43,8 +49,16 @@ export class DataService {
// });
}
public getRegion(id: number): Promise<Region> {
return this.http.get<Region>(`${this.API_URL}/regions/${id}`).toPromise();
public async getRegion(id: number): Promise<Region> {
if (this.regionCache.has(id)) {
console.log(`Served region ${id} from cache`);
return this.regionCache.get(id);
}
const region = await this.http.get<Region>(`${this.API_URL}/regions/${id}`).toPromise();
this.regionCache.set(id, region);
return region;
// return new Promise<Region>(resolve => {
// setTimeout(() => {
// resolve(MOCK_REGIONS.find(region => region.region_id === id));

View File

@ -11,5 +11,9 @@ export function objToBase64(obj: object): string {
* @param base64 Encoded object
*/
export function base64ToObj(base64: string): object {
if (!base64) {
return undefined;
}
return JSON.parse(atob(base64));
}

View File

@ -17,6 +17,7 @@
"cheap_water": "Cheap water",
"cheap_transportations": "Cheap public transport",
"cheap_entertainment": "Cheap entertainment",
"cheap_accommodation": "Cheap accommodation",
"warm": "warm",
"chilly": "cold",
"mild:": "mild",