diff --git a/cdk/cdk-workshop-stack.ts b/cdk/cdk-workshop-stack.ts index 39f5d0a..30028ac 100644 --- a/cdk/cdk-workshop-stack.ts +++ b/cdk/cdk-workshop-stack.ts @@ -1,13 +1,19 @@ -import { LambdaIntegration, RestApi } from '@aws-cdk/aws-apigateway'; +import { LambdaIntegration, RestApi, CfnIntegrationV2, CfnRouteV2, CfnApiV2 } from '@aws-cdk/aws-apigateway'; +// import * as gw from '@aws-cdk/aws-apigatewayv2'; import { CloudFrontWebDistribution, CloudFrontWebDistributionProps, PriceClass } from '@aws-cdk/aws-cloudfront'; import { CfnOutput, Construct, Duration, RemovalPolicy, Stack, StackProps } from '@aws-cdk/core'; -import { AttributeType, BillingMode, Table } from '@aws-cdk/aws-dynamodb'; -import { Code, Function, LayerVersion, Runtime } from '@aws-cdk/aws-lambda'; +import { AttributeType, BillingMode, Table, StreamViewType } from '@aws-cdk/aws-dynamodb'; +import { Code, Function, LayerVersion, Runtime, StartingPosition } from '@aws-cdk/aws-lambda'; import { Bucket, EventType } from '@aws-cdk/aws-s3'; import { BucketDeployment, Source } from '@aws-cdk/aws-s3-deployment'; import { LambdaDestination } from '@aws-cdk/aws-s3-notifications'; import { path as rootPath } from 'app-root-path'; import { resolve } from 'path'; +import { Topic } from '@aws-cdk/aws-sns'; +// import * as subs from '@aws-cdk/aws-sns-subscriptions' +// import { DynamoEventSource } from '@aws-cdk/aws-lambda-event-sources' + +import { PolicyStatement, Effect, Role, ServicePrincipal } from '@aws-cdk/aws-iam'; import { addCorsOptions } from './cors.utils'; import { WebIndex } from './web-index'; @@ -17,14 +23,21 @@ export interface CdkWorkshopStackProps extends StackProps { } export class CdkWorkshopStack extends Stack { - constructor(scope: Construct, id: string, props: CdkWorkshopStackProps) { + constructor (scope: Construct, id: string, props: CdkWorkshopStackProps) { super(scope, id, props); // API + const topic = new Topic(this, 'Topic', { + displayName: 'Pin topic', + topicName: 'PinTopic' + }); + const imageBucket = new Bucket(this, 'ImageBucket'); const pinTable = new Table(this, 'PinTable', { + replicationRegions: [process.env.AWS_REGION as string], + stream: StreamViewType.NEW_IMAGE, partitionKey: { name: 'pointUrl', type: AttributeType.STRING @@ -47,9 +60,40 @@ export class CdkWorkshopStack extends Stack { handler: 'pin-lambda.handler', environment: { IMAGE_BUCKET: imageBucket.bucketName, - PIN_TABLE: pinTable.tableName + PIN_TABLE: pinTable.tableName, + PIN_TOPIC: topic.topicArn ?? 'notopic' } }); + + const pinSocketFunction = new Function(this, 'PinSocket', { + code: apiCode, + runtime: Runtime.NODEJS_12_X, + handler: 'pin-socket-lambda.handler', + environment: { + PIN_STREAM: pinTable.tableName, + PIN_TOPIC: topic.topicArn + } + }); + + pinTable.grantStream(pinSocketFunction); + // pinSocketFunction.addEventSource( + // new DynamoEventSource(pinTableStream, { + // startingPosition: StartingPosition.LATEST, + // batchSize: 10 + // }) + // ) + + pinSocketFunction.addEventSourceMapping('PinStream', { + eventSourceArn: pinTable.tableStreamArn as string, + startingPosition: StartingPosition.LATEST + }); + + // new EventSourceMapping(this, 'PinStream', { + // eventSourceArn: pinTableStream.tableStreamArn, + // target: pinSocketFunction, + // startingPosition: StartingPosition.LATEST + // }) + imageBucket.grantReadWrite(pinHandler); pinTable.grantReadWriteData(pinHandler); @@ -89,6 +133,41 @@ export class CdkWorkshopStack extends Stack { const pinApi = api.root.addResource('pin'); + const wsApiGw = new CfnApiV2(this, 'pinSocketGw', { + name: 'pinSocketGw', + protocolType: 'WEBSOCKET', + routeSelectionExpression: '$request.body.message' + }); + const apiId = wsApiGw.ref + + const policy = new PolicyStatement({ + effect: Effect.ALLOW, + resources: [pinHandler.functionArn], + actions: ['lambda:InvokeFunction'] + }); + + const wsRole = new Role(this, 'socketGwRole', { + assumedBy: new ServicePrincipal('apigateway.amazonaws.com') + }); + + wsRole.addToPolicy(policy); + + const wsConnectIntegreation = new CfnIntegrationV2( + this, + 'wsConnectIntegreation', + { + apiId, + integrationType: 'AWS_PROXY', + integrationUri: `arn:aws:apigateway:${process.env.AWS_REGION}:lambda:path/2015-03-31/functions/${pinHandler.functionArn}/invocations`, + credentialsArn: wsRole.roleArn + } + ); + const wsConnect = new CfnRouteV2(this, 'pinSocketConnect', { + apiId, + routeKey: '$connect', + target: `integrations${wsConnectIntegreation.ref}` + }); + // OPTIONS /pin addCorsOptions(pinApi); // ANY /pin @@ -120,7 +199,7 @@ export class CdkWorkshopStack extends Stack { apiBaseUrl: api.url, source: webSource, bucket: webBucket - }); + }) webIndex.node.addDependency(webDeployment); diff --git a/lib/api/pin-lambda.ts b/lib/api/pin-lambda.ts index 748d34f..45499f2 100644 --- a/lib/api/pin-lambda.ts +++ b/lib/api/pin-lambda.ts @@ -1,5 +1,5 @@ import { APIGatewayProxyEvent, Context } from 'aws-lambda'; -import { DynamoDB } from 'aws-sdk'; +import { DynamoDB, SNS } from 'aws-sdk'; import { parse } from 'path'; import { v4 } from 'uuid'; @@ -13,8 +13,10 @@ const dynamo = new DynamoDB.DocumentClient({ endpoint: process.env.DYNAMODB_ENDPOINT }); +const sns = new SNS({ apiVersion: '2010-03-31' }); + export async function handler(event: APIGatewayProxyEvent, context: Context) { - context.callbackWaitsForEmptyEventLoop = false; + context.callbackWaitsForEmptyEventLoop = false const httpMethod = event.httpMethod.toUpperCase(); const pointUrl = event.pathParameters && event.pathParameters.pointUrl; @@ -24,16 +26,14 @@ export async function handler(event: APIGatewayProxyEvent, context: Context) { if (httpMethod === 'POST' && !pointUrl) { return await handleSave(event, sourceIp); - + } else if (httpMethod === 'PATCH' && pointUrl) { + return await handleRename(event, pointUrl); } else if (httpMethod === 'GET' && !pointUrl) { return await handleList(); - } else if (httpMethod === 'GET' && pointUrl) { return await handleGet(pointUrl); - } else if (httpMethod === 'DELETE' && pointUrl) { return await handleDelete(pointUrl); - } else { return transformResult({ statusCode: 404, body: { error: 'method/path not supported'} }); } @@ -64,9 +64,38 @@ async function handleSave(event: APIGatewayProxyEvent, sourceIp: string) { await dynamo.put({ TableName: pinTable, Item: savedPin }).promise(); const savedPinWithUrl = resolveSignedUrl(savedPin); + console.log('topiccArn: ', process.env.PIN_TOPIC); + sns.publish( + { + TopicArn: process.env.PIN_TOPIC, + Message: JSON.stringify(savedPinWithUrl) + }, + console.log + ) return transformResult({ body: savedPinWithUrl }); } +async function handleRename(event: APIGatewayProxyEvent, pointUrl: string) { + const { customName } = JSON.parse(event.body as string) as Partial + + const params: DynamoDB.DocumentClient.UpdateItemInput = { + TableName: pinTable, + Key: { pointUrl }, + UpdateExpression: 'set #customName = :customName', + ExpressionAttributeValues: { + ':customName': customName + }, + ExpressionAttributeNames: { + '#customName': 'customName' + }, + ReturnValues: 'ALL_NEW' + } + const resp = await dynamo.update(params).promise(); + + const body = resolveSignedUrl(resp.Attributes as SavedPin); + return transformResult({ body }); +} + // GET:/pin async function handleList() { const result = await dynamo.scan({ TableName: pinTable }).promise(); @@ -91,7 +120,7 @@ async function handleDelete(pointUrl: string) { await deleteImageFromS3(pinRecord); console.log('Deleting pin record: ', pointUrl); - await dynamo.delete({ TableName: pinTable, Key: { pointUrl }}) + await dynamo.delete({ TableName: pinTable, Key: { pointUrl } }) .promise(); return transformResult({ statusCode: 204 }); diff --git a/lib/api/pin-socket-lambda.ts b/lib/api/pin-socket-lambda.ts new file mode 100644 index 0000000..aa87f3d --- /dev/null +++ b/lib/api/pin-socket-lambda.ts @@ -0,0 +1,5 @@ +import { APIGatewayProxyEvent, Context } from 'aws-lambda' + +export function handler (event: APIGatewayProxyEvent, context: Context) { + console.log('STREAM', event) +} diff --git a/lib/shared/types/nominatim.types.ts b/lib/shared/types/nominatim.types.ts index 593cc2b..b444fe3 100644 --- a/lib/shared/types/nominatim.types.ts +++ b/lib/shared/types/nominatim.types.ts @@ -15,6 +15,7 @@ export interface GeocodeResponse { readonly category: string; readonly type: string; readonly importance: number; + readonly address: any; } export interface ReverseGeocodeResponse { @@ -33,4 +34,5 @@ export interface ReverseGeocodeResponse { readonly importance: number; readonly addresstype: string; readonly name: string; + readonly address: any; } diff --git a/lib/shared/types/pin.types.ts b/lib/shared/types/pin.types.ts index 7d76bb9..497de22 100644 --- a/lib/shared/types/pin.types.ts +++ b/lib/shared/types/pin.types.ts @@ -6,6 +6,7 @@ export type PinPoint = { } export interface Pin { + customName: string; point: PinPoint; address?: NominatimResponse; unsavedImage?: Image; diff --git a/lib/tsconfig.web.json b/lib/tsconfig.web.json index f5d92e3..2e36d1e 100644 --- a/lib/tsconfig.web.json +++ b/lib/tsconfig.web.json @@ -12,7 +12,9 @@ "experimentalDecorators": true, "moduleResolution": "node", "importHelpers": true, - "typeRoots": ["node_modules/@types"] + "typeRoots": ["node_modules/@types"], + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true }, "angularCompilerOptions": { "fullTemplateTypeCheck": true, diff --git a/lib/web/src/app/app.module.ts b/lib/web/src/app/app.module.ts index c642e85..d4171b5 100644 --- a/lib/web/src/app/app.module.ts +++ b/lib/web/src/app/app.module.ts @@ -27,7 +27,14 @@ import { NominatimService } from './services/nominatim.service'; import { PinState } from './state/pin.state'; import { environment } from '../environments/environment'; import { PinApiService } from './services/pin-api.service'; - +import { ShareDialogComponent } from './components/share-dialog.component'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatSelectModule } from '@angular/material/select'; +import { RouterModule } from '@angular/router'; +import { routes } from './routes'; +import { ImagePageComponent } from './components/image.page.component'; +import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; +import { TranslationBasicLoader } from './translation-loader'; @NgModule({ declarations: [ @@ -38,7 +45,9 @@ import { PinApiService } from './services/pin-api.service'; MapComponent, PinMarkerComponent, SearchComponent, - SidebarComponent + SidebarComponent, + ShareDialogComponent, + ImagePageComponent ], imports: [ BrowserModule, @@ -55,8 +64,18 @@ import { PinApiService } from './services/pin-api.service'; MatInputModule, MatProgressBarModule, MatProgressSpinnerModule, + MatDialogModule, + MatSelectModule, NgxsModule.forRoot([PinState], { developmentMode: !environment.production + }), + RouterModule.forRoot(routes), + TranslateModule.forRoot({ + defaultLanguage: 'en', + loader: { + provide: TranslateLoader, + useFactory: () => new TranslationBasicLoader() + } }) ], providers: [ @@ -64,9 +83,9 @@ import { PinApiService } from './services/pin-api.service'; PinApiService, { provide: Config, useFactory: configFactory, deps: [Meta] } ], - entryComponents: [PinMarkerComponent], + entryComponents: [PinMarkerComponent, ShareDialogComponent], bootstrap: [AppComponent] }) export class AppModule { - + } diff --git a/lib/web/src/app/components/app.component.ts b/lib/web/src/app/components/app.component.ts index ddac0a5..360f257 100644 --- a/lib/web/src/app/components/app.component.ts +++ b/lib/web/src/app/components/app.component.ts @@ -37,20 +37,19 @@ import { Pin } from 'shared/types/pin.types'; } `], template: ` -
- +
+ +
+
+
+
-
-
-
-
- -
` + +
` }) export class AppComponent { - + @Select(PinState.selectedPin) selectedPin$: Observable; - } diff --git a/lib/web/src/app/components/header.component.ts b/lib/web/src/app/components/header.component.ts index a433f89..2c4ad3d 100644 --- a/lib/web/src/app/components/header.component.ts +++ b/lib/web/src/app/components/header.component.ts @@ -1,4 +1,5 @@ import { Component, HostListener, Input, OnInit } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; import { Pin } from 'shared/types/pin.types'; @@ -24,36 +25,58 @@ import { Pin } from 'shared/types/pin.types'; flex: 0 0 260px; padding: 24px 16px 0px 16px; } + .langSelect { + padding: 24px 16px 0px 16px; + margin: 3px; + } `], template: ` - - - - -
CDK FULLSTACK WORKSHOP
-
- - + + + + +
CDK FULLSTACK WORKSHOP
+
+ + +
+ + {{ 'LANGUAGE' | translate }} + + + English + + + Slovenčina + + + +
` }) export class HeaderComponent implements OnInit { - + constructor (private translation: TranslateService) {} @Input() pin: Pin; - + + languages = { en: 'en', sk: 'sk' }; isDesktop: boolean; searchActive = false; - + ngOnInit(): void { + this.translation.use('sk'); this.onResize(); } - + + onLangSelect(event) { + this.translation.use(event.value); + } + @HostListener('window:resize') onResize() { this.isDesktop = window.innerWidth > 863; } - } diff --git a/lib/web/src/app/components/image.component.ts b/lib/web/src/app/components/image.component.ts index e93ce79..3d47b55 100644 --- a/lib/web/src/app/components/image.component.ts +++ b/lib/web/src/app/components/image.component.ts @@ -16,30 +16,32 @@ import { Image, SavedImage } from 'shared/types/pin.types'; } `], template: ` - - -
- -
-
-
{{ image.size | filesize }}
-
{{ image.lastModified | date }}
-
-
+ + +
+ +
+
+
{{ image.size | filesize }}
+
{{ image.lastModified | date }}
+
+
` }) export class ImageComponent implements OnChanges { - @Input() image: Partial; - + + @Input() diplayBottom: boolean = true + loading = true; - + ngOnChanges(changes: SimpleChanges): void { if (changes.image && this.image) { this.loading = true; } } - } diff --git a/lib/web/src/app/components/image.page.component.ts b/lib/web/src/app/components/image.page.component.ts new file mode 100644 index 0000000..8d3f0c7 --- /dev/null +++ b/lib/web/src/app/components/image.page.component.ts @@ -0,0 +1,63 @@ +import { Component } from '@angular/core'; +import { Image } from 'shared/types/pin.types'; +import { ActivatedRoute, Router, Params } from '@angular/router'; +import { switchMap, map, tap, catchError } from 'rxjs/operators'; +import { PinApiService } from '../services/pin-api.service'; +import { PinFromMap, SidebarInfo } from '../state/pin.state'; +import { Store } from '@ngxs/store'; +import { of, ObservableInput, Observable } from 'rxjs'; +import { SavedPin, SavedImage } from '../../../../shared/types/pin.types'; + +@Component({ + selector: 'app-image-page', + styles: [` + .error { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + } + `], + template: ` + + +
+

No pin found

+ +
+
+ ` +}) +export class ImagePageComponent { + constructor ( + private route: ActivatedRoute, + private pinApi: PinApiService, + private store: Store, + private router: Router + ) {} + + public hasError = false; + public pinUrl; + + public goHome() { + this.router.navigateByUrl(`/?pointUrl=${this.pinUrl}`); + } + + public image: Observable = this.route.params.pipe( + tap(params => { + this.pinUrl = params.id; + }), + switchMap>((params: Params) => this.pinApi.getPin(params.id)), + tap(pin => this.store.dispatch(new PinFromMap(pin.pointUrl))), + tap(() => this.store.dispatch(new SidebarInfo(true))), + map(pin => pin.image), + catchError>(() => { + this.hasError = true; + return of(null); + }) + ) +} diff --git a/lib/web/src/app/components/map.component.ts b/lib/web/src/app/components/map.component.ts index 8fd1ebd..96caf2f 100644 --- a/lib/web/src/app/components/map.component.ts +++ b/lib/web/src/app/components/map.component.ts @@ -4,8 +4,8 @@ import { ElementRef, OnChanges, OnInit, SimpleChanges, ViewEncapsulation } from import { Select, Store } from '@ngxs/store'; import * as L from 'leaflet'; import * as isEqual from 'lodash.isequal'; -import { Observable, Subscription } from 'rxjs'; -import { distinctUntilChanged, filter, first, map, withLatestFrom } from 'rxjs/operators'; +import { Observable, Subscription, of } from 'rxjs'; +import { distinctUntilChanged, filter, first, map, withLatestFrom, catchError, switchMap } from 'rxjs/operators'; import { PinMarkerComponent } from './pin-marker.component'; import { PinFromMap, PinState } from '../state/pin.state'; @@ -13,6 +13,8 @@ import { diffArrays } from '../utils/array.utils'; import { CustomIcon } from '../utils/leaflet.utils'; import { simpleChange } from '../utils/simple-change.operator'; import { PinPoint, Pin, SavedPin } from 'shared/types/pin.types'; +import { ActivatedRoute } from '@angular/router'; +import { PinApiService } from '../services/pin-api.service'; @Component({ selector: 'div[map]', // tslint:disable-line @@ -21,10 +23,10 @@ import { PinPoint, Pin, SavedPin } from 'shared/types/pin.types'; template: '' }) export class MapComponent implements OnInit, OnChanges, OnDestroy { - @Input() pin: Pin; @Select(PinState.pins) pins$: Observable; + @Select(PinState.selectedPin) selectedPin$: Observable; map: L.Map; selectedPinMarker: L.Marker; @@ -37,7 +39,9 @@ export class MapComponent implements OnInit, OnChanges, OnDestroy { private resolver: ComponentFactoryResolver, private appRef: ApplicationRef, private el: ElementRef, - private store: Store + private store: Store, + private route: ActivatedRoute, + private pinApi: PinApiService ) { L.Icon.Default.imagePath = 'assets/leaflet/'; } @@ -59,6 +63,23 @@ export class MapComponent implements OnInit, OnChanges, OnDestroy { ] }); + this.route.queryParams + .pipe( + first(), + filter(params => !!params.pointUrl), + map(p => p.pointUrl as string), + switchMap(pointUrl => + this.pinApi.getPin(pointUrl).pipe( + map(() => pointUrl), + catchError(e => of(pointUrl)) + ) + ) + ) + .subscribe(pointUrl => { + const [lat, lng] = pointUrl.split(',').map(n => parseFloat(n)) + this.store.dispatch(new PinFromMap({ lat, lng })) + }) + this.subscriptions.push( this.pins$ .pipe( @@ -91,15 +112,37 @@ export class MapComponent implements OnInit, OnChanges, OnDestroy { // zoom to pins this.pins$.pipe( - filter(pins => pins && !!pins.length), - first() - ).subscribe(pins => { - const points = pins.map(pin => pin.point); - this.map.fitBounds(L.latLngBounds(points), { padding: [60, 60], maxZoom: 14 }); - }); + filter(pins => pins && !!pins.length), + first() + ).subscribe(pins => { + const points = pins.map(pin => pin.point); + this.map.fitBounds(L.latLngBounds(points), { padding: [60, 60], maxZoom: 14 }); + }); this.selectedPinMarker = L.marker(initialCenter); + this.subscriptions.push( + this.selectedPin$.subscribe(pin => { + this.pin = pin + if (this.map) { + if (this.pin && !(this.pin as SavedPin).pointUrl) { + // add unsaved pin marker + this.selectedPinMarker.setLatLng(this.pin.point) + if (!this.map.hasLayer(this.selectedPinMarker)) { + this.selectedPinMarker.addTo(this.map) + } + } else { + // remove unsaved pin marker + this.selectedPinMarker.removeFrom(this.map) + } + if (this.pin) { + this.ensurePointInView(this.pin.point) + } + setTimeout(() => this.map.invalidateSize({ pan: false })) + } + }) + ) + L.control.scale().addTo(this.map); this.map.on('click', (e: L.LeafletMouseEvent) => { @@ -161,6 +204,4 @@ export class MapComponent implements OnInit, OnChanges, OnDestroy { }; return pinMarkerIcon; } - - } diff --git a/lib/web/src/app/components/pin-marker.component.ts b/lib/web/src/app/components/pin-marker.component.ts index 2ed05c4..9c02241 100644 --- a/lib/web/src/app/components/pin-marker.component.ts +++ b/lib/web/src/app/components/pin-marker.component.ts @@ -14,25 +14,26 @@ import { Pin, SavedImage, SavedPin } from 'shared/types/pin.types'; templateUrl: './pin-marker.component.html' }) export class PinMarkerComponent implements OnInit, OnDestroy, AfterViewInit { - @Select(PinState.selectedPin) selectedPin$: Observable; @Select(PinState.pins) pins$: Observable; selected$: Observable; pin$: Observable; - + thumbnail: SavedImage; - + subscription: Subscription; public onDestroyCallback: () => void; @ViewChild('marker', { static: true }) public marker: ElementRef; - constructor(private elm: ElementRef, - private store: Store, - private pinApi: PinApiService, - @Inject('pointUrl') private pointUrl: string) {} + constructor( + private elm: ElementRef, + private store: Store, + private pinApi: PinApiService, + @Inject('pointUrl') private pointUrl: string + ) {} ngOnInit() { this.selected$ = this.selectedPin$.pipe( @@ -69,13 +70,13 @@ export class PinMarkerComponent implements OnInit, OnDestroy, AfterViewInit { ngAfterViewInit() { this.adjustMarker(); } - + @HostListener('click', ['$event']) onClick(event: MouseEvent) { this.store.dispatch(new PinFromMap(this.pointUrl)); event.stopPropagation(); } - + ngOnDestroy() { if (this.subscription) { this.subscription.unsubscribe(); @@ -86,12 +87,11 @@ export class PinMarkerComponent implements OnInit, OnDestroy, AfterViewInit { private adjustMarker() { const arrowHeight = 8; const alignWidth = 32; // align in px from left corner - + const parentElement = this.elm.nativeElement.parentElement; if (parentElement) { const height = this.marker.nativeElement.offsetHeight + arrowHeight; parentElement.style.margin = `-${height}px 0 0 -${alignWidth}px`; } } - } diff --git a/lib/web/src/app/components/search.component.ts b/lib/web/src/app/components/search.component.ts index f9d2770..d08c537 100644 --- a/lib/web/src/app/components/search.component.ts +++ b/lib/web/src/app/components/search.component.ts @@ -18,7 +18,6 @@ import { NominatimService } from '../services/nominatim.service'; import { PinFromSearch } from '../state/pin.state'; import { Pin } from 'shared/types/pin.types'; - @Component({ selector: 'app-search', styles: [` @@ -30,49 +29,48 @@ import { Pin } from 'shared/types/pin.types'; } `], template: ` - - - - - - - - - - - {{ pin.address?.display_name }} - - - + + + + + + + + + + + {{ pin.address?.display_name }} + + + ` }) export class SearchComponent implements OnInit, OnChanges { - @Input() pin: Pin; @Input() searchActive: boolean; - + @Output() searchActiveChange = new EventEmitter(); - + @ViewChild('searchInput', { static: true }) searchInput: ElementRef; - + searchControl = new FormControl(); geocodedPins$: Observable; - + constructor(private nominatim: NominatimService, private store: Store) {} - + ngOnInit(): void { const query$ = this.searchControl.valueChanges.pipe(debounceTime(100)); - + this.geocodedPins$ = query$.pipe( switchMap(query => query && query.length >= 3 ? this.nominatim.getLocation(query) @@ -89,7 +87,7 @@ export class SearchComponent implements OnInit, OnChanges { startWith(undefined) ); } - + ngOnChanges(changes: SimpleChanges): void { if (changes.pin && !changes.pin.firstChange) { this.clearSearch(); @@ -102,7 +100,7 @@ export class SearchComponent implements OnInit, OnChanges { } } } - + @HostListener('document:keyup.escape') public clearSearch() { this.searchControl.setValue(null); @@ -111,9 +109,8 @@ export class SearchComponent implements OnInit, OnChanges { this.searchActiveChange.emit(false); }); } - + selectPin(pin: Pin) { this.store.dispatch(new PinFromSearch(pin)); } - } diff --git a/lib/web/src/app/components/share-dialog.component.html b/lib/web/src/app/components/share-dialog.component.html new file mode 100644 index 0000000..7c3929d --- /dev/null +++ b/lib/web/src/app/components/share-dialog.component.html @@ -0,0 +1,7 @@ +

Share url

+Click the link to copy +
+ +
diff --git a/lib/web/src/app/components/share-dialog.component.scss b/lib/web/src/app/components/share-dialog.component.scss new file mode 100644 index 0000000..73e4843 --- /dev/null +++ b/lib/web/src/app/components/share-dialog.component.scss @@ -0,0 +1,12 @@ +pre.share { + background-color: #e0dfdf; + padding: 10px; + border-radius: 12px; +} + +div.share { + display: flex; + flex-direction: row; + justify-content: flex-start; + cursor: pointer; +} diff --git a/lib/web/src/app/components/share-dialog.component.ts b/lib/web/src/app/components/share-dialog.component.ts new file mode 100644 index 0000000..61dd25a --- /dev/null +++ b/lib/web/src/app/components/share-dialog.component.ts @@ -0,0 +1,30 @@ +import { Component, Inject, OnInit } from '@angular/core' +import { MAT_DIALOG_DATA } from '@angular/material/dialog' + +export interface DialogData { + pointUrl: string +} + +@Component({ + selector: 'app-share-dialog', + templateUrl: 'share-dialog.component.html', + styleUrls: ['./share-dialog.component.scss'] +}) +export class ShareDialogComponent implements OnInit { + constructor (@Inject(MAT_DIALOG_DATA) private data: DialogData) {} + public shareUrl: string + + ngOnInit (): void { + const current = new URL(window.location.toString()) + this.shareUrl = `${current.origin}${current.pathname}?pointUrl=${this.data.pointUrl}` + } + + public copyToClipboard () { + const tempInput = document.createElement('input') + tempInput.value = this.shareUrl + document.body.appendChild(tempInput) + tempInput.select() + document.execCommand('copy') + document.body.removeChild(tempInput) + } +} diff --git a/lib/web/src/app/components/sidebar.component.ts b/lib/web/src/app/components/sidebar.component.ts index 2a3faad..25b06e2 100644 --- a/lib/web/src/app/components/sidebar.component.ts +++ b/lib/web/src/app/components/sidebar.component.ts @@ -1,14 +1,21 @@ import { Component, Input, OnChanges, SimpleChanges, ViewChild } from '@angular/core'; -import { Store } from '@ngxs/store'; +import { Store, Select } from '@ngxs/store'; import { ImageInputComponent } from './image-input.component'; -import { DeletePin, SavePin, UnselectPin } from '../state/pin.state'; +import { DeletePin, SavePin, UnselectPin, PinState, RenamePin } from '../state/pin.state'; import { PinPoint, SavedPin, Image } from 'shared/types/pin.types'; import { toDMS } from 'shared/utils/point.utils'; +import { MatDialog } from '@angular/material/dialog'; +import { ShareDialogComponent } from './share-dialog.component'; +import { Observable } from 'rxjs'; +import { FormGroup, FormControl } from '@angular/forms'; @Component({ selector: 'app-sidebar', styles: [` + .form { + padding: 5px; + } .close { position: absolute !important; top: 0px; @@ -19,76 +26,128 @@ import { toDMS } from 'shared/utils/point.utils'; } `], template: ` - - - {{ printPoint(pin.point) }} - - - - {{ pin.address?.display_name || pin.address?.error }} - -
- - - - Download Image - - -
-
- - - - - - -
-
+ + + {{ printPoint(pin.point) }} + + + + {{ pin.address?.display_name || pin.address?.error }} + + +
+ + {{ 'PIN_NAME_LABEL' | translate }} + + + +
+
+ +

{{ 'CREATED' | translate }}: {{ pin.created | date }}

+

{{ 'IMAGE' | translate }}

+

{{ 'NAME' | translate }}: {{ pin.image?.name }}

+

{{ 'SIZE' | translate }}: {{ pin.image?.size | filesize }}

+
+
+ + + + {{ 'DOWNLOAD_IMAGE' | translate }} + + + +
+
+ + + + + + +
+
` }) export class SidebarComponent implements OnChanges { - @Input() pin: Partial; - + + @Select(PinState.sidebarInfo) info$: Observable; + @ViewChild(ImageInputComponent, { static: false }) private imageInputComponent: ImageInputComponent; - unsavedImage: Image; - - constructor(private store: Store) {} - + + constructor(private store: Store, private dialog: MatDialog) {} + + public pinName = new FormGroup( + { + name: new FormControl() + }, + { updateOn: 'change' } + ); + ngOnChanges(changes: SimpleChanges): void { if (changes.pin && this.pin.pointUrl) { this.unsavedImage = undefined; } + + this.pinName.setValue({ name: this.getPinName() }); + } + + rename() { + if (this.pin.pointUrl) { + this.store.dispatch(new RenamePin(this.pinName.get('name').value)); + } } - + selectImage() { this.imageInputComponent.openFileDialog(); } - + unselectPin() { this.store.dispatch(new UnselectPin()); } - + savePinWithImage(unsavedImage: Image) { this.unsavedImage = unsavedImage; - this.store.dispatch(new SavePin(unsavedImage)); + this.store.dispatch( + new SavePin(unsavedImage, this.pinName.get('name').value) + ); } - + deletePin() { this.store.dispatch(new DeletePin()); } - + + share() { + const pointUrl = this.pin.pointUrl + ? this.pin.pointUrl + : `${this.pin.point.lat},${this.pin.point.lng}` + this.dialog.open(ShareDialogComponent, { + data: { pointUrl } + }); + } + printPoint(point: PinPoint): string { return toDMS(point.lat) + ', ' + toDMS(point.lng); } - + + getPinName (): string { + let name = ''; + + name = !!this.pin.customName ? this.pin.customName : name; + return name; + } } diff --git a/lib/web/src/app/routes.ts b/lib/web/src/app/routes.ts new file mode 100644 index 0000000..015a736 --- /dev/null +++ b/lib/web/src/app/routes.ts @@ -0,0 +1,14 @@ +import { Routes } from '@angular/router' +import { MapComponent } from './components/map.component' +import { ImagePageComponent } from './components/image.page.component' + +export const routes: Routes = [ + { + path: '', + component: MapComponent + }, + { + path: ':id', + component: ImagePageComponent + } +] diff --git a/lib/web/src/app/services/pin-api.service.ts b/lib/web/src/app/services/pin-api.service.ts index c47dc0b..088598f 100644 --- a/lib/web/src/app/services/pin-api.service.ts +++ b/lib/web/src/app/services/pin-api.service.ts @@ -7,31 +7,33 @@ import { Image, Pin, SavedPin } from 'shared/types/pin.types'; @Injectable() export class PinApiService { - private pinApiUrl: string; - + constructor(private http: HttpClient, config: Config) { this.pinApiUrl = config.apiBaseUrl + 'pin'; } - + listPins(): Observable { return this.http .get(this.pinApiUrl); } - - savePin(pin: Pin, unsavedImage: Image): Observable { + + savePin(pin: Pin, unsavedImage: Image, customName: string): Observable { return this.http - .post(this.pinApiUrl, { ...pin, unsavedImage }); + .post(this.pinApiUrl, { ...pin, customName, unsavedImage }); } - + + renamePin(pointUrl: string, customName: string) { + return this.http.patch(`${this.pinApiUrl}/${pointUrl}`, { customName }); + } + deletePin(pointUrl: string): Observable { return this.http .delete(this.pinApiUrl + '/' + pointUrl); } - + getPin(pointUrl: string): Observable { return this.http .get(this.pinApiUrl + '/' + pointUrl); } - } diff --git a/lib/web/src/app/state/pin.state.ts b/lib/web/src/app/state/pin.state.ts index 4365240..7b34da9 100644 --- a/lib/web/src/app/state/pin.state.ts +++ b/lib/web/src/app/state/pin.state.ts @@ -23,7 +23,12 @@ export class UnselectPin { export class SavePin { static readonly type = '[pin] save'; - constructor(public unsavedImage: Image) {} + constructor(public unsavedImage: Image, public customName: string = '') {} +} + +export class RenamePin { + static readonly type = '[pin] rename'; + constructor(public customName: string) {} } export class DeletePin { @@ -35,42 +40,51 @@ export class GeocodePinPoint { constructor(public point) {} } +export class SidebarInfo { + static readonly type = '[pin] sidebar info'; + constructor(public sidebarInfo) {} +} + // State model export interface PinStateModel { pins: SavedPin[]; selectedPin: Pin | SavedPin; inProgress: boolean; + sidebarInfo: boolean; } // Reducers + effects @State({ name: 'pin', - defaults: { pins: [], selectedPin: undefined, inProgress: false } + defaults: { pins: [], selectedPin: undefined, inProgress: false, sidebarInfo: false } }) export class PinState implements NgxsOnInit { - - @Selector() static pins(state: PinStateModel) { + @Selector() static pins (state: PinStateModel) { return state.pins; } - + @Selector() static selectedPin(state: PinStateModel) { return state.selectedPin; } - + + @Selector() static sidebarInfo(state: PinStateModel) { + return state.sidebarInfo; + } + constructor( private store: Store, private nominatim: NominatimService, private pinApi: PinApiService ) {} - + ngxsOnInit(ctx?: StateContext): void | any { this.pinApi.listPins().subscribe( pins => ctx.patchState({ pins }) ); } - + @Action(PinFromMap) pinFromMap(ctx: StateContext, { point }: PinFromMap) { const savedPin = this.findSavedPin(ctx, point); @@ -81,24 +95,29 @@ export class PinState implements NgxsOnInit { this.store.dispatch(new GeocodePinPoint(point)); } } - + + @Action(SidebarInfo) + sidebarInfo(ctx: StateContext, value: boolean) { + ctx.patchState({ sidebarInfo: value }); + } + @Action(PinFromSearch) pinFromSearch(ctx: StateContext, { pin }: PinFromSearch) { const savedPin = this.findSavedPin(ctx, pin.point); ctx.patchState({ selectedPin: savedPin || pin }); } - + @Action(UnselectPin) unselectPin(ctx: StateContext) { ctx.patchState({ selectedPin: undefined }); } - + @Action(SavePin) - savePin(ctx: StateContext, { unsavedImage }: SavePin) { + savePin(ctx: StateContext, { unsavedImage, customName }: SavePin) { const { selectedPin } = ctx.getState(); // is unsaved pin if (!(selectedPin as SavedPin).pointUrl) { - this.pinApi.savePin(selectedPin, unsavedImage) + this.pinApi.savePin(selectedPin, unsavedImage, customName) .subscribe(savedPin => { const { pins } = ctx.getState(); ctx.patchState({ @@ -108,7 +127,24 @@ export class PinState implements NgxsOnInit { }); } } - + + @Action(RenamePin) + renamePin(ctx: StateContext, { customName }: RenamePin) { + const { selectedPin } = ctx.getState(); + const pointUrl = (selectedPin as SavedPin).pointUrl + // is unsaved pin + if (pointUrl) { + this.pinApi + .renamePin((selectedPin as SavedPin).pointUrl, customName) + .subscribe(savedPin => { + const { pins } = ctx.getState(); + const newPins = this.updatePins(savedPin, pins); + + ctx.patchState({ pins: newPins }); + }); + } + } + @Action(DeletePin) deletePin(ctx: StateContext) { const { selectedPin } = ctx.getState(); @@ -118,13 +154,12 @@ export class PinState implements NgxsOnInit { const { pins, selectedPin } = ctx.getState(); const { point, address } = selectedPin; ctx.patchState({ - pins: pins.filter(pin => pin.pointUrl !== pointUrl), - selectedPin: { point, address } + pins: pins.filter(pin => pin.pointUrl !== pointUrl) }) }); } } - + @Action(GeocodePinPoint) geocodePinPoint(ctx: StateContext, { point }: GeocodePinPoint) { this.nominatim.getAddress(point).subscribe(address => { @@ -132,11 +167,17 @@ export class PinState implements NgxsOnInit { ctx.patchState({ selectedPin: { ...selectedPin, address } }) }); } - + private findSavedPin(ctx: StateContext, point: PinPoint | string): SavedPin | undefined { const pointUrl = typeof point === 'string' ? point : pointToUrl(point); const { pins } = ctx.getState(); return pins.find(pin => pin.pointUrl === pointUrl); } - + + private updatePins(pin: SavedPin, pins: SavedPin[]): SavedPin[] { + const newPins = Array.from(pins); + newPins.splice(pins.findIndex(p => p.pointUrl === pin.pointUrl), 1, pin); + + return newPins; + } } diff --git a/lib/web/src/app/translation-loader.ts b/lib/web/src/app/translation-loader.ts new file mode 100644 index 0000000..70f0086 --- /dev/null +++ b/lib/web/src/app/translation-loader.ts @@ -0,0 +1,15 @@ +import { TranslateLoader } from '@ngx-translate/core' +import sk from '../assets/i18n/sk.json' +import en from '../assets/i18n/en.json' +import { of, Observable } from 'rxjs' + +export class TranslationBasicLoader implements TranslateLoader { + languages = { + sk, + en + } + + getTranslation (lang: string): Observable { + return of(this.languages[lang]) + } +} diff --git a/lib/web/src/assets/i18n/en.json b/lib/web/src/assets/i18n/en.json new file mode 100644 index 0000000..c201416 --- /dev/null +++ b/lib/web/src/assets/i18n/en.json @@ -0,0 +1,16 @@ +{ + "SEARCH_PLACEHOLDER": "Click into the map or search here", + "NAME": "Name", + "SIZE": "Size", + "CREATED": "Created", + "IMAGE": "Image", + "SHARE": "Share", + "DELETE_PIN": "Delete Pin", + "DOWNLOAD_IMAGE": "Download Image", + "PIN_IMAGE": "Pin Image", + "CLOSE": "close", + "PIN_NAME_LABEL": "Custom name", + "PIN_CHANGE_NAME": "Change name", + "LANGUAGE": "Language", + "SEARCH": "search" +} diff --git a/lib/web/src/assets/i18n/sk.json b/lib/web/src/assets/i18n/sk.json new file mode 100644 index 0000000..ea15162 --- /dev/null +++ b/lib/web/src/assets/i18n/sk.json @@ -0,0 +1,16 @@ +{ + "SEARCH_PLACEHOLDER": "Kliknite do mapy, alebo hľadajte tu", + "NAME": "Meno", + "SIZE": "Veľkosť", + "CREATED": "Vztvorené", + "IMAGE": "Obrázok", + "SHARE": "Zdielať", + "DELETE_PIN": "Zmazať", + "DOWNLOAD_IMAGE": "Stiahnuť", + "PIN_IMAGE": "Pripnúť obrázok", + "CLOSE": "zavrieť", + "PIN_NAME_LABEL": "Meno", + "PIN_CHANGE_NAME": "Zmeniť meno", + "LANGUAGE": "Jazyk", + "SEARCH": "vyhľadať" +} diff --git a/lib/webpack.api.js b/lib/webpack.api.js index 51f734e..a00a7f1 100644 --- a/lib/webpack.api.js +++ b/lib/webpack.api.js @@ -10,6 +10,7 @@ const config = { entry: { 'hello-lambda': './lib/api/hello-lambda.ts', 'pin-lambda': './lib/api/pin-lambda.ts', + 'pin-socket-lambda': './lib/api/pin-socket-lambda.ts', 'thumbnail-lambda': './lib/api/thumbnail-lambda.ts' }, externals: [...awsModules, ...layerModules], diff --git a/package.json b/package.json index 9a9c055..e3695ca 100644 --- a/package.json +++ b/package.json @@ -43,14 +43,20 @@ "@angular/material": "^8.2.3", "@angular/platform-browser": "^8.2.11", "@angular/platform-browser-dynamic": "^8.2.11", + "@angular/router": "^8.2.11", + "@ngx-translate/core": "12.1.2", "@aws-cdk/aws-apigateway": "^1.14.0", "@aws-cdk/aws-cloudformation": "^1.14.0", "@aws-cdk/aws-cloudfront": "^1.14.0", "@aws-cdk/aws-dynamodb": "^1.14.0", + "@aws-cdk/aws-iam": "^1.49.1", "@aws-cdk/aws-lambda": "^1.14.0", + "@aws-cdk/aws-lambda-event-sources": "^1.14.0", "@aws-cdk/aws-s3": "^1.14.0", "@aws-cdk/aws-s3-deployment": "^1.14.0", "@aws-cdk/aws-s3-notifications": "^1.14.0", + "@aws-cdk/aws-sns": "^1.14.0", + "@aws-cdk/aws-sqs": "^1.14.0", "@aws-cdk/core": "^1.14.0", "@ngxs/store": "^3.5.1", "app-root-path": "^2.2.1", diff --git a/serverless.yml b/serverless.yml index 299fdf7..7028e1a 100644 --- a/serverless.yml +++ b/serverless.yml @@ -57,7 +57,11 @@ resources: ProvisionedThroughput: ReadCapacityUnits: 1 WriteCapacityUnits: 1 - + pinTopicLocal: + Type: AWS::SNS::Topic + Properties: + TopicName: PinTopic + DisplayName: 'Pin topic' functions: hello: handler: 'dist/api/hello-lambda.handler' @@ -87,3 +91,10 @@ functions: event: s3:ObjectCreated:* rules: - prefix: original + pin-socket: + handler: 'dist/pin-socket.handler' + events: + - stream: + type: dynamodb + arn: + Fn::GetAtt: [pinTableLocal, StreamArn]