Add bookmarks
This commit is contained in:
parent
587e231e05
commit
9c3f70d36b
@ -4,12 +4,14 @@ import {HomeComponent} from './containers/home/home.component';
|
|||||||
import {NotfoundComponent} from './containers/notfound/notfound.component';
|
import {NotfoundComponent} from './containers/notfound/notfound.component';
|
||||||
import {SearchComponent} from './containers/search/search.component';
|
import {SearchComponent} from './containers/search/search.component';
|
||||||
import {RegionDetailsComponent} from './containers/region-details/region-details.component';
|
import {RegionDetailsComponent} from './containers/region-details/region-details.component';
|
||||||
|
import {BookmarkListComponent} from './containers/bookmark-list/bookmark-list.component';
|
||||||
|
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{path: 'home', component: HomeComponent},
|
{path: 'home', component: HomeComponent},
|
||||||
{path: 'search', component: SearchComponent},
|
{path: 'search', component: SearchComponent},
|
||||||
{path: 'region/:id', component: RegionDetailsComponent},
|
{path: 'region/:id', component: RegionDetailsComponent},
|
||||||
|
{path: 'bookmark', component: BookmarkListComponent},
|
||||||
{path: '', redirectTo: 'home', pathMatch: 'full'},
|
{path: '', redirectTo: 'home', pathMatch: 'full'},
|
||||||
{path: '**', component: NotfoundComponent}
|
{path: '**', component: NotfoundComponent}
|
||||||
];
|
];
|
||||||
|
|||||||
@ -9,10 +9,16 @@
|
|||||||
<div class="nav">
|
<div class="nav">
|
||||||
<a mat-button routerLink="home" (click)="drawer.close()">
|
<a mat-button routerLink="home" (click)="drawer.close()">
|
||||||
<mat-icon>home</mat-icon>
|
<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()">
|
<a mat-button routerLink="impressum" (click)="drawer.close()">
|
||||||
<mat-icon>subject</mat-icon>
|
<mat-icon>subject</mat-icon>
|
||||||
<span>Impressum</span></a>
|
<span>Impressum</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</mat-drawer>
|
</mat-drawer>
|
||||||
<div class="routed-component-wrapper">
|
<div class="routed-component-wrapper">
|
||||||
|
|||||||
@ -21,13 +21,15 @@ import {TranslateModule, TranslateService} from '@ngx-translate/core';
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import * as enLang from '../assets/i18n/en.json';
|
import * as enLang from '../assets/i18n/en.json';
|
||||||
import {HttpClientModule} from '@angular/common/http';
|
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 {FormsModule} from '@angular/forms';
|
||||||
import {RegionComponent} from './components/region/region.component';
|
import {RegionComponent} from './components/region/region.component';
|
||||||
import {ResultComponent} from './components/result/result.component';
|
import {ResultComponent} from './components/result/result.component';
|
||||||
import {RegionDetailsComponent} from './containers/region-details/region-details.component';
|
import {RegionDetailsComponent} from './containers/region-details/region-details.component';
|
||||||
import {GraphComponent} from './components/graph/graph.component';
|
import {GraphComponent} from './components/graph/graph.component';
|
||||||
import {RegionStatsComponent} from './components/region-stats/region-stats.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({
|
@NgModule({
|
||||||
@ -41,7 +43,9 @@ import {RegionStatsComponent} from './components/region-stats/region-stats.compo
|
|||||||
ResultComponent,
|
ResultComponent,
|
||||||
RegionDetailsComponent,
|
RegionDetailsComponent,
|
||||||
GraphComponent,
|
GraphComponent,
|
||||||
RegionStatsComponent
|
RegionStatsComponent,
|
||||||
|
BookmarkButtonComponent,
|
||||||
|
BookmarkListComponent
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
@ -61,7 +65,8 @@ import {RegionStatsComponent} from './components/region-stats/region-stats.compo
|
|||||||
MatCheckboxModule,
|
MatCheckboxModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
MatButtonToggleModule,
|
MatButtonToggleModule,
|
||||||
MatDividerModule
|
MatDividerModule,
|
||||||
|
MatTooltipModule
|
||||||
],
|
],
|
||||||
providers: [],
|
providers: [],
|
||||||
bootstrap: [AppComponent]
|
bootstrap: [AppComponent]
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,13 +1,12 @@
|
|||||||
<div class="region-mat-card">
|
<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-footer">
|
||||||
<div class="region-title">
|
<div class="region-title">
|
||||||
<span class="region-name">{{region.name}}</span>
|
<span class="region-name">{{region.name}}</span>
|
||||||
<span class="region-country">| {{region.country}}</span>
|
<span class="region-country">| {{region.country}}</span>
|
||||||
</div>
|
</div>
|
||||||
<button mat-icon-button>
|
<app-bookmark-button [region]="region"></app-bookmark-button>
|
||||||
<mat-icon>bookmark</mat-icon>
|
|
||||||
</button>
|
|
||||||
<button mat-icon-button>
|
<button mat-icon-button>
|
||||||
<mat-icon>share</mat-icon>
|
<mat-icon>share</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
<div class="result-mat-card">
|
<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-title">
|
||||||
<div class="result-name">
|
<div class="result-name">
|
||||||
<span class="result-name">{{result.name}}</span>
|
<span class="result-name">{{result.name}}</span>
|
||||||
<span class="result-country">| {{result.country}}</span>
|
<span class="result-country">| {{result.country}}</span>
|
||||||
</div>
|
</div>
|
||||||
<button mat-icon-button>
|
<app-bookmark-button [region]="result"></app-bookmark-button>
|
||||||
<mat-icon>bookmark</mat-icon>
|
|
||||||
</button>
|
|
||||||
<button mat-icon-button>
|
<button mat-icon-button>
|
||||||
<mat-icon>share</mat-icon>
|
<mat-icon>share</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,13 +1,12 @@
|
|||||||
<div *ngIf="region">
|
<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-details-header">
|
||||||
<div class="region-title">
|
<div class="region-title">
|
||||||
<span class="region-country">{{region.country}}</span>
|
<span class="region-country">{{region.country}}</span>
|
||||||
<span class="region-name">{{region.name}}</span>
|
<span class="region-name">{{region.name}}</span>
|
||||||
</div>
|
</div>
|
||||||
<button mat-icon-button>
|
<app-bookmark-button [region]="region"></app-bookmark-button>
|
||||||
<mat-icon>bookmark</mat-icon>
|
|
||||||
</button>
|
|
||||||
<button mat-icon-button>
|
<button mat-icon-button>
|
||||||
<mat-icon>share</mat-icon>
|
<mat-icon>share</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
12
frontend/src/app/services/bookmark.service.spec.ts
Normal file
12
frontend/src/app/services/bookmark.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
63
frontend/src/app/services/bookmark.service.ts
Normal file
63
frontend/src/app/services/bookmark.service.ts
Normal 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,6 +11,8 @@ export class DataService {
|
|||||||
|
|
||||||
private readonly API_URL = 'https://travopti.de/api/v1';
|
private readonly API_URL = 'https://travopti.de/api/v1';
|
||||||
|
|
||||||
|
private readonly regionCache = new Map<number, Region>();
|
||||||
|
|
||||||
constructor(private http: HttpClient) {
|
constructor(private http: HttpClient) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,8 +36,12 @@ export class DataService {
|
|||||||
// });
|
// });
|
||||||
}
|
}
|
||||||
|
|
||||||
public getAllRegions(): Promise<Region[]> {
|
public async getAllRegions(): Promise<Region[]> {
|
||||||
return this.http.get<Region[]>(this.API_URL + '/regions').toPromise();
|
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 => {
|
// return new Promise<Region[]>(resolve => {
|
||||||
// setTimeout(() => {
|
// setTimeout(() => {
|
||||||
// resolve(MOCK_REGIONS);
|
// resolve(MOCK_REGIONS);
|
||||||
@ -43,8 +49,16 @@ export class DataService {
|
|||||||
// });
|
// });
|
||||||
}
|
}
|
||||||
|
|
||||||
public getRegion(id: number): Promise<Region> {
|
public async getRegion(id: number): Promise<Region> {
|
||||||
return this.http.get<Region>(`${this.API_URL}/regions/${id}`).toPromise();
|
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 => {
|
// return new Promise<Region>(resolve => {
|
||||||
// setTimeout(() => {
|
// setTimeout(() => {
|
||||||
// resolve(MOCK_REGIONS.find(region => region.region_id === id));
|
// resolve(MOCK_REGIONS.find(region => region.region_id === id));
|
||||||
|
|||||||
@ -11,5 +11,9 @@ export function objToBase64(obj: object): string {
|
|||||||
* @param base64 Encoded object
|
* @param base64 Encoded object
|
||||||
*/
|
*/
|
||||||
export function base64ToObj(base64: string): object {
|
export function base64ToObj(base64: string): object {
|
||||||
|
if (!base64) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
return JSON.parse(atob(base64));
|
return JSON.parse(atob(base64));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
"cheap_water": "Cheap water",
|
"cheap_water": "Cheap water",
|
||||||
"cheap_transportations": "Cheap public transport",
|
"cheap_transportations": "Cheap public transport",
|
||||||
"cheap_entertainment": "Cheap entertainment",
|
"cheap_entertainment": "Cheap entertainment",
|
||||||
|
"cheap_accommodation": "Cheap accommodation",
|
||||||
"warm": "warm",
|
"warm": "warm",
|
||||||
"chilly": "cold",
|
"chilly": "cold",
|
||||||
"mild:": "mild",
|
"mild:": "mild",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user