drag & drop renewed, imageCropper revisited, imageOrder persisted, css quirks

This commit is contained in:
Andreas Knuth 2024-03-31 19:44:08 +02:00
parent 89bb85a512
commit a437851f6d
28 changed files with 767 additions and 203 deletions

View File

@ -80,13 +80,14 @@ export class FileService {
return false
}
}
async storePropertyPicture(file: Express.Multer.File, listingId: string) {
async storePropertyPicture(file: Express.Multer.File, listingId: string) : Promise<string> {
const suffix = file.mimetype.includes('png') ? 'png' : 'jpg'
const directory = `./pictures/property/${listingId}`
fs.ensureDirSync(`${directory}`);
const imageName = await this.getNextImageName(directory);
//await fs.outputFile(`${directory}/${imageName}`, file.buffer);
await this.resizeImageToAVIF(file.buffer,150 * 1024,imageName,directory);
return `${imageName}.avif`
}
async getNextImageName(directory) {
try {

View File

@ -4,11 +4,15 @@ import { Logger } from 'winston';
import { FileInterceptor } from '@nestjs/platform-express';
import { FileService } from '../file/file.service.js';
import { SelectOptionsService } from '../select-options/select-options.service.js';
import { ListingsService } from '../listings/listings.service.js';
import { CommercialPropertyListing } from 'src/models/main.model.js';
import { Entity, EntityData } from 'redis-om';
@Controller('image')
export class ImageController {
constructor(private fileService:FileService,
private listingService:ListingsService,
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
private selectOptions:SelectOptionsService) {
}
@ -16,7 +20,8 @@ export class ImageController {
@Post('uploadPropertyPicture/:id')
@UseInterceptors(FileInterceptor('file'),)
async uploadPropertyPicture(@UploadedFile() file: Express.Multer.File,@Param('id') id:string) {
await this.fileService.storePropertyPicture(file,id);
const imagename = await this.fileService.storePropertyPicture(file,id);
await this.listingService.addImage(id,imagename);
}
@Post('uploadProfile/:id')
@ -33,7 +38,16 @@ export class ImageController {
@Get(':id')
async getPropertyImagesById(@Param('id') id:string): Promise<any> {
return await this.fileService.getPropertyImages(id);
const result = await this.listingService.getCommercialPropertyListingById(id);
const listing = result as CommercialPropertyListing;
if (listing.imageOrder){
return listing.imageOrder
} else {
const imageOrder = await this.fileService.getPropertyImages(id);
listing.imageOrder=imageOrder;
this.listingService.saveListing(listing);
return imageOrder;
}
}
@Get('profileImages/:userids')
async getProfileImagesForUsers(@Param('userids') userids:string): Promise<any> {
@ -43,9 +57,11 @@ export class ImageController {
async getCompanyLogosForUsers(@Param('userids') userids:string): Promise<any> {
return await this.fileService.getCompanyLogosForUsers(userids);
}
@Delete('propertyPicture/:listingid/:imagename')
async deletePropertyImagesById(@Param('listingid') listingid:string,@Param('imagename') imagename:string): Promise<any> {
this.fileService.deleteImage(`pictures/property/${listingid}/${imagename}`)
this.fileService.deleteImage(`pictures/property/${listingid}/${imagename}`);
await this.listingService.deleteImage(listingid,imagename);
}
@Delete('logo/:userid/')
async deleteLogoImagesById(@Param('id') id:string): Promise<any> {

View File

@ -3,8 +3,11 @@ import { ImageController } from './image.controller.js';
import { ImageService } from './image.service.js';
import { FileService } from '../file/file.service.js';
import { SelectOptionsService } from '../select-options/select-options.service.js';
import { ListingsService } from '../listings/listings.service.js';
import { ListingsModule } from '../listings/listings.module.js';
@Module({
imports: [ListingsModule],
controllers: [ImageController],
providers: [ImageService,FileService,SelectOptionsService]
})

View File

@ -1,9 +1,10 @@
import { Body, Controller, Delete, Get, Inject, Param, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
import { Body, Controller, Delete, Get, Inject, Param, Post, Put, UploadedFile, UseInterceptors } from '@nestjs/common';
import { ListingsService } from './listings.service.js';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston';
import { FileInterceptor } from '@nestjs/platform-express';
import { FileService } from '../file/file.service.js';
import { CommercialPropertyListing, ImageProperty } from 'src/models/main.model.js';
@Controller('listings/commercialProperty')
export class CommercialPropertyListingsController {
@ -21,7 +22,11 @@ export class CommercialPropertyListingsController {
find(@Body() criteria: any): any {
return this.listingsService.findCommercialPropertyListings(criteria);
}
@Put('imageOrder/:id')
async changeImageOrder(@Param('id') id:string,@Body() imageOrder: ImageProperty[]) {
this.listingsService.updateImageOrder(id, imageOrder)
}
/**
* @param listing creates a new listing
*/

View File

@ -12,6 +12,7 @@ import { UserService } from '../user/user.service.js';
@Module({
imports: [RedisModule],
controllers: [BusinessListingsController, CommercialPropertyListingsController,UnknownListingsController,BrokerListingsController],
providers: [ListingsService,FileService,UserService]
providers: [ListingsService,FileService,UserService],
exports: [ListingsService],
})
export class ListingsModule {}

View File

@ -3,7 +3,8 @@ import {
BusinessListing,
CommercialPropertyListing,
ListingCriteria,
ListingType
ListingType,
ImageProperty
} from '../models/main.model.js';
import { convertStringToNullUndefined } from '../utils.js';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
@ -77,8 +78,8 @@ export class ListingsService {
}
return result;
}
async getCommercialPropertyListingById(id: string) {
return await this.commercialPropertyListingRepository.fetch(id)
async getCommercialPropertyListingById(id: string): Promise<CommercialPropertyListing>{
return await this.commercialPropertyListingRepository.fetch(id) as unknown as CommercialPropertyListing;
}
async getBusinessListingById(id: string) {
return await this.businessListingRepository.fetch(id)
@ -134,4 +135,23 @@ export class ListingsService {
}
return listings
}
async updateImageOrder(id:string,imageOrder: ImageProperty[]){
const listing = await this.getCommercialPropertyListingById(id) as unknown as CommercialPropertyListing
listing.imageOrder=imageOrder;
this.saveListing(listing);
}
async deleteImage(listingid:string,name:string,){
const listing = await this.getCommercialPropertyListingById(listingid) as unknown as CommercialPropertyListing
const index = listing.imageOrder.findIndex(im=>im.name===name);
if (index>-1){
listing.imageOrder.splice(index,1);
this.saveListing(listing);
}
}
async addImage(id:string,imagename: string){
const listing = await this.getCommercialPropertyListingById(id) as unknown as CommercialPropertyListing
listing.imageOrder.push({name:imagename,code:'',id:''});
this.saveListing(listing);
}
}

View File

@ -30,6 +30,7 @@
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@types/uuid": "^9.0.8",
"angular-cropperjs": "^14.0.1",
"angular-mixed-cdk-drag-drop": "^2.2.3",
"browser-bunyan": "^1.8.0",
"cropperjs": "^1.6.1",
"express": "^4.18.2",

View File

@ -44,13 +44,15 @@ export class ImageCropperComponent {
}
}
sendImage(){
this.loadingService.startLoading('uploadImage');
setTimeout(()=>{
this.angularCropper.cropper.getCroppedCanvas().toBlob(async(blob) => {
this.fileUpload.clear()
this.ref.close(blob);
}, 'image/png');
})
// setTimeout(()=>{
// this.angularCropper.cropper.getCroppedCanvas().toBlob(async(blob) => {
// this.ref.close(blob);
// this.fileUpload.clear()
// }, 'image/jpg');
// },0)
this.fileUpload.clear()
this.ref.close(this.angularCropper.cropper);
}
cancelUpload(){

View File

@ -8,7 +8,7 @@
<div
class="flex justify-content-between lg:block border-top-1 lg:border-top-none border-gray-800 py-3 lg:py-0 mt-3 lg:mt-0">
@if(userService.isLoggedIn()){
<p-button label="Account" class="ml-3 font-bold" [outlined]="true" severity="secondary" [routerLink]="['/account',(user$|async).id]"></p-button>
<p-button label="Account" class="ml-3 font-bold" [outlined]="true" severity="secondary" [routerLink]="['/account',(user$|async)?.id]"></p-button>
} @else {
<p-button label="Log In" class="ml-3 font-bold" [outlined]="true" severity="secondary" (click)="login()"></p-button>
}

View File

@ -86,37 +86,38 @@
</div>
}
@for (listing of filteredListings; track listing.id) {
<div *ngIf="listing.listingsCategory==='commercialProperty'" class="col-12 xl:col-4">
<div class="surface-card p-2" style="border-radius: 10px">
<article class="flex flex-column md:flex-row w-full gap-3 p-3 surface-card">
<div class="relative">
<img src="{{environment.apiBaseUrl}}/property/{{listing.id}}/1.avif" alt="Image" class="border-round w-full h-full md:w-12rem md:h-9rem">
<p class="absolute px-2 py-1 border-round-lg text-sm font-normal text-white mt-0 mb-0" style="background-color: rgba(255, 255, 255, 0.3); backdrop-filter: blur(10px); top: 3%; left: 3%;">{{selectOptions.getState(listing.state)}}</p>
</div>
<div class="flex flex-column w-full gap-3">
<div class="flex w-full justify-content-between align-items-center flex-wrap gap-3">
<p class="font-semibold text-lg mt-0 mb-0">{{listing.title}}</p>
<!-- <p-rating [ngModel]="val1" readonly="true" stars="5" [cancel]="false" ngClass="flex-shrink-0"></p-rating> -->
<div *ngIf="listing.listingsCategory==='commercialProperty'" class="col-12 xl:col-4 flex">
<div class="surface-card p-2 flex flex-column flex-grow-1 justify-content-between" style="border-radius: 10px">
<article class="flex flex-column md:flex-row w-full gap-3 p-3 surface-card">
<div class="relative">
@if (listing.imageOrder.length>0){
<img src="{{environment.apiBaseUrl}}/property/{{listing.id}}/{{listing.imageOrder[0].name}}" alt="Image" class="border-round w-full h-full md:w-12rem md:h-9rem">
} @else {
<!-- <img src="{{environment.apiBaseUrl}}/property/{{listing.id}}/{{listing.imageOrder[0].name}}" alt="Image" class="border-round w-full h-full md:w-12rem md:h-9rem"> -->
<img src="assets/images/placeholder_properties.jpg" alt="Image" class="border-round w-full h-full md:w-12rem md:h-9rem" />
}
<p class="absolute px-2 py-1 border-round-lg text-sm font-normal text-white mt-0 mb-0" style="background-color: rgba(255, 255, 255, 0.3); backdrop-filter: invert(30%);; top: 3%; left: 3%;">{{selectOptions.getState(listing.state)}}</p>
</div>
<p class="font-normal text-lg text-600 mt-0 mb-0">{{listing.city}}</p>
<div class="flex flex-wrap justify-content-between xl:h-2rem mt-auto">
<p class="text-base flex align-items-center text-900 mt-0 mb-1">
<i class="pi pi-list mr-2"></i>
<span class="font-medium">{{selectOptions.getCommercialProperty(listing.type)}}</span>
</p>
<!-- <p class="text-base flex align-items-center text-900 mt-0 mb-1">
<i class="pi pi-check-circle mr-2"></i>
<span class="font-medium">Verified</span>
</p> -->
<div class="flex flex-column w-full gap-3">
<div class="flex w-full justify-content-between align-items-center flex-wrap gap-3">
<p class="font-semibold text-lg mt-0 mb-0">{{listing.title}}</p>
<!-- <p-rating [ngModel]="val1" readonly="true" stars="5" [cancel]="false" ngClass="flex-shrink-0"></p-rating> -->
</div>
<p class="font-normal text-lg text-600 mt-0 mb-0">{{listing.city}}</p>
<div class="flex flex-wrap justify-content-between xl:h-2rem mt-auto">
<p class="text-base flex align-items-center text-900 mt-0 mb-1">
<i class="pi pi-list mr-2"></i>
<span class="font-medium">{{selectOptions.getCommercialProperty(listing.type)}}</span>
</p>
</div>
<p class="font-semibold text-3xl text-900 mt-0 mb-2">{{listing.price | currency}}</p>
</div>
<p class="font-semibold text-3xl text-900 mt-0 mb-2">{{listing.price | currency}}</p>
</div>
</article>
<div class="px-4 py-3 text-left">
<button pButton pRipple icon="pi pi-arrow-right" iconPos="right" label="View Full Listing"
class="p-button-rounded p-button-success" [routerLink]="['/details-listing/commercialProperty',listing.id]"></button>
</div>
</div>
</article>
<div class="px-4 py-3 text-left ">
<button pButton pRipple icon="pi pi-arrow-right" iconPos="right" label="View Full Listing"
class="p-button-rounded p-button-success" [routerLink]="['/details-listing/commercialProperty',listing.id]"></button>
</div>
</div>
</div>
}
@ -126,7 +127,7 @@
<div class="surface-card p-4 flex flex-column align-items-center md:flex-row md:align-items-stretch h-full" >
<span>
@if(user.hasProfile){
<img src="{{environment.apiBaseUrl}}/profile/{{user.id}}.avif" class="w-5rem" />
<img src="{{environment.apiBaseUrl}}/profile/{{user.id}}.avif?_ts={{ts}}" class="w-5rem" />
} @else {
<img src="assets/images/person_placeholder.jpg" class="w-5rem" />
}
@ -147,9 +148,7 @@
class="p-button-rounded p-button-success" [routerLink]="['/details-user',user.id]"></button>
</div>
<!-- <div class="mt-auto ml-auto">
<img *ngIf="user.hasCompanyLogo" src="{{environment.apiBaseUrl}}/logo/{{user.id}}" class="rounded-image"/>
</div> -->
</div>
</div>
}

View File

@ -43,6 +43,7 @@ export class ListingsComponent {
first: number = 0;
rows: number = 12;
totalRecords:number = 0;
ts = new Date().getTime()
public category: 'business' | 'commercialProperty' | 'professionals_brokers' | undefined;
constructor(public selectOptions: SelectOptionsService,
@ -118,4 +119,5 @@ export class ListingsComponent {
imageErrorHandler(listing: ListingType) {
listing.hideImage = true; // Bild ausblenden, wenn es nicht geladen werden kann
}
}

View File

@ -133,27 +133,24 @@ export class AccountComponent {
'640px': '90vw'
},
});
this.dialogRef.onClose.subscribe(blob => {
if (blob) {
this.imageUploadService.uploadImage(blob, type==='company'?'uploadCompanyLogo':'uploadProfile',this.user.id).subscribe(async(event) => {
if (event.type === HttpEventType.UploadProgress) {
const progress = event.total ? event.loaded / event.total : 0;
console.log(`Upload-Fortschritt: ${progress * 100}%`);
} else if (event.type === HttpEventType.Response) {
console.log('Upload abgeschlossen', event.body);
this.loadingService.stopLoading('uploadImage');
if (this.type==='company'){
this.user.hasCompanyLogo=true;
this.companyLogoUrl=`${environment.apiBaseUrl}/logo/${this.user.id}.avif?_ts=${new Date().getTime()}`
} else {
this.user.hasProfile=true;
this.profileUrl=`${environment.apiBaseUrl}/profile/${this.user.id}.avif?_ts=${new Date().getTime()}`
this.dialogRef.onClose.subscribe(cropper => {
if (cropper){
this.loadingService.startLoading('uploadImage');
cropper.getCroppedCanvas().toBlob(async (blob) => {
this.imageUploadService.uploadImage(blob, type==='company'?'uploadCompanyLogo':'uploadProfile',this.user.id).subscribe(async(event) => {
if (event.type === HttpEventType.Response) {
this.loadingService.stopLoading('uploadImage');
if (this.type==='company'){
this.user.hasCompanyLogo=true;
this.companyLogoUrl=`${environment.apiBaseUrl}/logo/${this.user.id}.avif?_ts=${new Date().getTime()}`
} else {
this.user.hasProfile=true;
this.profileUrl=`${environment.apiBaseUrl}/profile/${this.user.id}.avif?_ts=${new Date().getTime()}`
}
}
}
}, error => console.error('Fehler beim Upload:', error));
}, error => console.error('Fehler beim Upload:', error));
})
}
});
})
}
}

View File

@ -94,30 +94,19 @@
</div>
</div>
</div>
<!-- <p-carousel [value]="propertyImages" [numVisible]="3" [numScroll]="3" [circular]="false" [responsiveOptions]="responsiveOptions">
<ng-template let-image pTemplate="item">
<div class="border-1 surface-border border-round m-2 text-center py-5 px-3">
<div class="mb-3">
<img src="{{environment.apiBaseUrl}}/property/{{listing.id}}/{{image.name}}" [alt]="image.name" class="w-11 shadow-2" />
</div>
<div>
<div class="mt-5 flex align-items-center justify-content-center gap-2">
<p-button icon="pi pi-file-edit" [rounded]="true" />
<p-button icon="fa-solid fa-trash-can" [rounded]="true" severity="danger" (click)="deleteConfirm(image.name)"></p-button>
</div>
</div>
</div>
</ng-template>
</p-carousel> -->
<div class="p-2 border-1 surface-border border-round mb-4 image-container" cdkDropList (cdkDropListDropped)="onDrop($event)"
<div class="p-2 border-1 surface-border border-round mb-4 image-container" cdkDropListGroup mixedCdkDragDrop
(dropped)="onDrop($event)"
cdkDropListOrientation="horizontal">
<ul >
<li *ngFor="let image of propertyImages" class="p-2 border-round shadow-1" >
<!-- <div class="image-container"> -->
<img src="{{environment.apiBaseUrl}}/property/{{listing.id}}/{{image.name}}" [alt]="image.name" class="shadow-2" cdkDrag/>
<!-- </div> -->
</li>
</ul>
@for (image of propertyImages; track image) {
<span cdkDropList mixedCdkDropList>
<div cdkDrag mixedCdkDragSizeHelper class="image-wrap">
<img src="{{environment.apiBaseUrl}}/property/{{listing.id}}/{{image.name}}"
[alt]="image.name" class="shadow-2" cdkDrag>
<fa-icon [icon]="faTrash" (click)="deleteConfirm(image.name)"></fa-icon>
</div>
</span>
}
</div>
}
@if (listing.listingsCategory==='business'){

View File

@ -2,86 +2,56 @@
transform: translateY(5px);
}
// .image {
// width: 120px;
// height: 30px;
// border: 1px solid #6b7280;
// padding: 1px 1px;
// object-fit: contain;
// }
// .image-container img {
// width: 200px;
// box-shadow: 0 3px 6px #00000029, 0 3px 6px #0000003b;
// margin-right: 1rem;
// }
// .container {
// width: 100%;
// min-height: 200px;
// border: 1px solid #ccc;
// display: flex;
// flex-wrap: wrap;
// }
.image-container {
width: 100%;
/* oder eine spezifische Breite */
overflow-x: auto;
/* Ermöglicht das Scrollen, wenn die Bilder zu breit sind */
}
display: flex; /* Erlaubt ein flexibles Box-Layout */
flex-wrap: wrap; /* Erlaubt das Umfließen der Elemente auf die nächste Zeile */
justify-content: flex-start; /* Startet die Anordnung der Elemente am Anfang des Containers */
align-items: flex-start; /* Ausrichtung der Elemente am Anfang der Querachse */
padding: 10px; /* Abstand zwischen den Inhalten des Containers und dessen Rand */
}
.image-container ul {
.image-container span {
flex-flow: row;
display: flex;
padding: 0;
/* Entfernt den Standard-Abstand des ul-Elements */
margin: 0;
/* Entfernt den Standard-Außenabstand des ul-Elements */
list-style-type: none;
/* Entfernt die Listenpunkte */
width: fit-content;
height: fit-content;
}
.image-container li {
flex: 1 1 auto;
/* Erlaubt den li-Elementen, zu wachsen und zu schrumpfen, aber füllt den Raum gleichmäßig */
/* Optional: Füge hier Abstände zwischen den li-Elementen hinzu */
}
.image-container img {
max-width: 100%;
/* Stellt sicher, dass die Bilder nicht über ihre natürliche Größe hinaus wachsen */
height: auto;
/* Behält das Seitenverhältnis bei */
.image-container span img {
max-height: 150px; /* Maximale Höhe der Bilder */
width: auto; /* Die Breite der Bilder passt sich automatisch an die Höhe an */
cursor: pointer;
margin: 10px;
}
// .image-container fa-icon {
// top: 0; /* Positioniert das Icon am oberen Rand des Bildes */
// right: 0; /* Positioniert das Icon am rechten Rand des Bildes */
// color: #fff; /* Weiße Farbe für das Icon */
// background-color: rgba(0,0,0,0.5); /* Halbtransparenter Hintergrund für bessere Sichtbarkeit */
// padding: 5px; /* Ein wenig Platz um das Icon */
// cursor: pointer; /* Verwandelt den Cursor in eine Hand, um Interaktivität anzudeuten */
// }
.draggable-image {
margin: 8px;
cursor: grab;
}
.draggable-image:active {
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
}
// .cdk-drag-preview {
// box-shadow: 0 5px 5px rgba(0, 0, 0, 0.2);
// }
// .cdk-drag-placeholder {
// background-color: #ccc;
// }
.drop-area {
border: 2px dashed #ccc;
padding: 20px;
text-align: center;
transition: all 0.3s;
}
/* CSS-Klasse für den Drop-Bereich, wenn ein Element darüber gezogen wird */
.drop-area-active {
border-color: #2196F3;
background-color: #E3F2FD;
}
.image-wrap {
position: relative; /* Ermöglicht die absolute Positionierung des Icons bezogen auf diesen Container */
display: inline-block; /* Erlaubt die Inline-Anordnung, falls mehrere Bilder vorhanden sind */
}
/* Stil für das Bild */
.image-wrap img {
max-height: 150px;
width: auto;
display: block; /* Verhindert unerwünschten Abstand unter dem Bild */
}
/* Stil für das FontAwesome Icon */
.image-wrap fa-icon {
position: absolute;
top: 15px; /* Positioniert das Icon am oberen Rand des Bildes */
right: 15px; /* Positioniert das Icon am rechten Rand des Bildes */
color: #fff; /* Weiße Farbe für das Icon */
background-color: rgba(0,0,0,0.5); /* Halbtransparenter Hintergrund für bessere Sichtbarkeit */
padding: 5px; /* Ein wenig Platz um das Icon */
cursor: pointer; /* Verwandelt den Cursor in eine Hand, um Interaktivität anzudeuten */
border-radius: 8px; /* Optional: Abrunden der linken unteren Ecke für ästhetische Zwecke */
}

View File

@ -43,10 +43,14 @@ import { DialogService, DynamicDialogModule, DynamicDialogRef } from 'primeng/dy
import { ImageCropperComponent } from '../../../components/image-cropper/image-cropper.component';
import { ConfirmDialogModule } from 'primeng/confirmdialog';
import { CdkDragDrop, CdkDragEnter, CdkDragExit, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
import { MixedCdkDragDropModule } from 'angular-mixed-cdk-drag-drop';
import { faTrash } from '@fortawesome/free-solid-svg-icons';
@Component({
selector: 'create-listing',
standalone: true,
imports: [SharedModule, ArrayToStringPipe, InputNumberModule, CarouselModule, DialogModule, AngularCropperjsModule, FileUploadModule, EditorModule, DynamicDialogModule, ConfirmDialogModule,DragDropModule],
imports: [SharedModule, ArrayToStringPipe, InputNumberModule, CarouselModule,
DialogModule, AngularCropperjsModule, FileUploadModule, EditorModule, DynamicDialogModule, DragDropModule,
ConfirmDialogModule, MixedCdkDragDropModule],
providers: [MessageService, DialogService, ConfirmationService],
templateUrl: './edit-listing.component.html',
styleUrl: './edit-listing.component.scss'
@ -85,8 +89,8 @@ export class EditListingComponent {
config = { aspectRatio: 16 / 9 }
editorModules = TOOLBAR_OPTIONS
dialogRef: DynamicDialogRef | undefined;
draggedImage:ImageProperty
dropAreaActive = false;
draggedImage: ImageProperty
faTrash = faTrash;
constructor(public selectOptions: SelectOptionsService,
private router: Router,
private activatedRoute: ActivatedRoute,
@ -155,18 +159,35 @@ export class EditListingComponent {
'640px': '90vw'
},
});
this.dialogRef.onClose.subscribe(blob => {
if (blob) {
this.imageService.uploadImage(blob, 'uploadPropertyPicture', this.listing.id).subscribe(async (event) => {
if (event.type === HttpEventType.Response) {
console.log('Upload abgeschlossen', event.body);
this.loadingService.stopLoading('uploadImage');
this.propertyImages = await this.listingsService.getPropertyImages(this.listing.id)
}
}, error => console.error('Fehler beim Upload:', error));
this.dialogRef.onClose.subscribe(cropper => {
if (cropper){
this.loadingService.startLoading('uploadImage');
cropper.getCroppedCanvas().toBlob(async (blob) => {
this.imageService.uploadImage(blob, 'uploadPropertyPicture', this.listing.id).subscribe(async (event) => {
if (event.type === HttpEventType.Response) {
console.log('Upload abgeschlossen', event.body);
this.loadingService.stopLoading('uploadImage');
this.propertyImages = await this.listingsService.getPropertyImages(this.listing.id)
}
}, error => console.error('Fehler beim Upload:', error));
}, 'image/jpg');
cropper.destroy();
}
});
})
// this.dialogRef.onClose.subscribe(blob => {
// if (blob) {
// // this.loadingService.startLoading('uploadImage');
// setTimeout(()=>{
// this.imageService.uploadImage(blob, 'uploadPropertyPicture', this.listing.id).subscribe(async (event) => {
// if (event.type === HttpEventType.Response) {
// console.log('Upload abgeschlossen', event.body);
// // this.loadingService.stopLoading('uploadImage');
// this.propertyImages = await this.listingsService.getPropertyImages(this.listing.id)
// }
// }, error => console.error('Fehler beim Upload:', error));
// },10)
// }
// });
}
deleteConfirm(imageName: string) {
@ -193,19 +214,9 @@ export class EditListingComponent {
});
}
onDrop(event: CdkDragDrop<string[]>) {
this.dropAreaActive = false;
onDrop(event: { previousIndex: number; currentIndex: number }) {
moveItemInArray(this.propertyImages, event.previousIndex, event.currentIndex);
//console.log(event.previousIndex, event.currentIndex);
}
onDragEnter(event: CdkDragEnter<any,any>) {
this.dropAreaActive = true;
}
onDragExit(event: CdkDragExit<any,any>) {
this.dropAreaActive = false;
this.listingsService.changeImageOrder(this.listing.id, this.propertyImages)
}
}

View File

@ -20,7 +20,7 @@ export class ImageService {
return this.http.post(uploadUrl, formData,{
// headers: this.headers,
reportProgress: true,
//reportProgress: true,
observe: 'events',
});
}

View File

@ -36,4 +36,7 @@ export class ListingsService {
async getPropertyImages(id:string):Promise<ImageProperty[]>{
return await lastValueFrom(this.http.get<ImageProperty[]>(`${this.apiBaseUrl}/bizmatch/image/${id}`));
}
async changeImageOrder(id:string, propertyImages: ImageProperty[]):Promise<ImageProperty[]>{
return await lastValueFrom(this.http.put<ImageProperty[]>(`${this.apiBaseUrl}/bizmatch/listings/commercialProperty/imageOrder/${id}`,propertyImages));
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View File

@ -0,0 +1,3 @@
[ZoneTransfer]
LastWriterPackageFamilyName=Microsoft.Windows.Photos_8wekyb3d8bbwe
ZoneId=3

View File

@ -59,7 +59,7 @@ export interface CommercialPropertyListing extends Listing {
email?: string;
website?: string;
phoneNumber?: string;
hasImages:boolean;
imageOrder?:ImageProperty[];
}
export type ListingType =
| BusinessListing

192
crawler/data/listings.json Normal file
View File

@ -0,0 +1,192 @@
[
{
"userId": "8a2b1c5d-7e6f-4g3h-9i1j-2k3l4m5n6o7p",
"listingsCategory": "business",
"title": "Gourmet Coffee Shop",
"description": "<h2>Community-Centric Café</h2><p>A beloved local coffee shop offering a wide variety of gourmet coffees, teas, and handmade pastries. Known for its cozy atmosphere and strong community ties. Ideal for someone passionate about coffee and community engagement.</p>",
"type": "2",
"state": "CA",
"city": "San Francisco",
"id": "02JRPQ2YPK7CMXS55XP9G8UIJ2",
"price": 220000,
"salesRevenue": 340000,
"temporary": false,
"leasedLocation": false,
"established": 2015,
"employees": 10,
"reasonForSale": "Retirement",
"supportAndTraining": "2 weeks of training provided.",
"cashFlow": 95000
},
{
"userId": "4q5r6s7t-8u9v-0w1x-2y3z-4a5b6c7d8e9f",
"listingsCategory": "business",
"title": "Organic Farm Supply",
"description": "<h2>Sustainable Agriculture</h2><p>Offering a range of products for organic farming, including seeds, tools, and fertilizers. Committed to promoting sustainable farming practices. A great opportunity for someone with a green thumb and a passion for sustainability.</p>",
"type": "3",
"state": "OR",
"city": "Portland",
"id": "03KRQQ3ZPL8DNYS66YQ1H9VJK3",
"price": 180000,
"salesRevenue": 260000,
"temporary": false,
"leasedLocation": true,
"established": 2017,
"employees": 6,
"reasonForSale": "Focusing on other ventures",
"supportAndTraining": "1 month of comprehensive training.",
"cashFlow": 78000
},
{
"userId": "3m4n5o6p-7q8r-9s0t-1u2v-3w4x5y6z7a8b",
"listingsCategory": "business",
"title": "Modern Italian Restaurant",
"description": "<h2>Authentic Italian Cuisine</h2><p>An upscale Italian restaurant known for its modern twist on traditional dishes. Features a full bar and an elegant dining room. A fantastic opportunity for a culinary entrepreneur or chef.</p>",
"type": "4",
"state": "NY",
"city": "New York",
"id": "04LRPP4AOQ9ENZT77ZR2I0WKL4",
"price": 450000,
"salesRevenue": 670000,
"temporary": false,
"leasedLocation": false,
"established": 2012,
"employees": 15,
"reasonForSale": "New business interests",
"supportAndTraining": "Owner willing to consult for 3 months.",
"cashFlow": 220000
},
{
"userId": "4q5r6s7t-8u9v-0w1x-2y3z-4a5b6c7d8e9f",
"listingsCategory": "business",
"title": "Boutique Clothing Store",
"description": "<h2>High-End Fashion</h2><p>A boutique store offering an exclusive selection of women's clothing and accessories from top designers. Located in a prime shopping district, it's known for its exceptional customer service and unique fashion finds.</p>",
"type": "5",
"state": "IL",
"city": "Chicago",
"id": "05MSQQ5BPRAFO1U88ZS3J1XML5",
"price": 310000,
"salesRevenue": 430000,
"temporary": false,
"leasedLocation": true,
"established": 2018,
"employees": 8,
"reasonForSale": "Personal reasons",
"supportAndTraining": "3 weeks of training and support.",
"cashFlow": 120000
},
{
"userId": "3m4n5o6p-7q8r-9s0t-1u2v-3w4x5y6z7a8b",
"listingsCategory": "business",
"title": "Tech Startup - Mobile Apps",
"description": "<h2>Innovative Technology Solutions</h2><p>A tech startup specializing in developing cutting-edge mobile applications for both iOS and Android. With several successful apps already in the market, this is an exciting opportunity for tech enthusiasts or investors.</p>",
"type": "6",
"state": "WA",
"city": "Seattle",
"id": "06NTQR6CQSBGP2V99AT4K2YNM6",
"price": 500000,
"salesRevenue": 800000,
"temporary": false,
"leasedLocation": false,
"established": 2020,
"employees": 20,
"reasonForSale": "Seeking new challenges",
"supportAndTraining": "Technical and business support for 1 month.",
"cashFlow": 300000
},
{
"userId": "8a2b1c5d-7e6f-4g3h-9i1j-2k3l4m5n6o7p",
"listingsCategory": "business",
"title": "Eco-Friendly Cleaning Service",
"description": "<h2>Green Home and Office Cleaning</h2><p>A fast-growing cleaning service that uses eco-friendly products. Offers home and office cleaning with a focus on sustainability and environmentally friendly practices. A perfect venture for those passionate about the environment.</p>",
"type": "7",
"state": "CO",
"city": "Denver",
"id": "07OUQR7DRTCGQ3WAAAU5L3ZON7",
"price": 90000,
"salesRevenue": 150000,
"temporary": false,
"leasedLocation": true,
"established": 2021,
"employees": 12,
"reasonForSale": "Expanding to new markets",
"supportAndTraining": "Comprehensive eco-friendly cleaning training.",
"cashFlow": 65000
},
{
"userId": "3m4n5o6p-7q8r-9s0t-1u2v-3w4x5y6z7a8b",
"listingsCategory": "business",
"title": "Independent Bookstore",
"description": "<h2>A Haven for Book Lovers</h2><p>An independent bookstore with a strong community presence, offering a wide range of books, from bestsellers to local authors. Includes a cozy café space for readers to relax and enjoy their books.</p>",
"type": "8",
"state": "MA",
"city": "Boston",
"id": "08PVRS8ESUDHR4XBBBV6M4APO8",
"price": 130000,
"salesRevenue": 210000,
"temporary": false,
"leasedLocation": false,
"established": 2016,
"employees": 5,
"reasonForSale": "Owner relocating",
"supportAndTraining": "Training on operations and inventory management.",
"cashFlow": 70000
},
{
"userId": "7w8x9y0z-1a2b-3c4d-5e6f-7g8h9i0j1k2l",
"listingsCategory": "business",
"title": "Luxury Spa and Wellness Center",
"description": "<h2>Ultimate Relaxation and Wellness</h2><p>A luxury spa offering a comprehensive range of services, including massages, facials, and wellness programs. Situated in a tranquil and upscale area, it's the perfect retreat for health and wellness enthusiasts.</p>",
"type": "9",
"state": "FL",
"city": "Miami",
"id": "09QWTR9FTVEIS5YCCDW7N5BQP9",
"price": 350000,
"salesRevenue": 520000,
"temporary": false,
"leasedLocation": true,
"established": 2014,
"employees": 18,
"reasonForSale": "Pursuing other interests",
"supportAndTraining": "Owner available for a smooth transition over 2 months.",
"cashFlow": 190000
},
{
"userId": "1g2h3i4j-5k6l-7m8n-9o0p-1q2r3s4t5u6v",
"listingsCategory": "business",
"title": "Custom Jewelry Shop",
"description": "<h2>Exquisite Handcrafted Jewelry</h2><p>A shop specializing in custom-made jewelry, from engagement rings to unique pieces for special occasions. Utilizes high-quality materials and offers a personalized design service.</p>",
"type": "10",
"state": "NV",
"city": "Las Vegas",
"id": "10RXUS0GUWFJT6ZDDDX8O6CQR0",
"price": 275000,
"salesRevenue": 400000,
"temporary": false,
"leasedLocation": false,
"established": 2018,
"employees": 4,
"reasonForSale": "Health reasons",
"supportAndTraining": "Design and crafting training provided.",
"cashFlow": 110000
},
{
"userId": "4q5r6s7t-8u9v-0w1x-2y3z-4a5b6c7d8e9f",
"listingsCategory": "business",
"title": "Digital Marketing Agency",
"description": "<h2>Online Branding and Marketing Solutions</h2><p>A full-service digital marketing agency offering SEO, PPC, social media marketing, and web design services. Known for its innovative strategies and results-driven approach. Ideal for those with a background in marketing or business.</p>",
"type": "11",
"state": "TX",
"city": "Dallas",
"id": "11SYVT1HVXGKU7AEEEX9P7DRR1",
"price": 400000,
"salesRevenue": 600000,
"temporary": false,
"leasedLocation": true,
"established": 2019,
"employees": 25,
"reasonForSale": "Scaling down",
"supportAndTraining": "4 weeks of digital marketing training.",
"cashFlow": 250000
}
]

View File

@ -0,0 +1,142 @@
[
{
"id": "2b5c900f-894d-5e48-c987-8cf735170b5f",
"temporary": false,
"userId": "",
"listingsCategory": "commercialProperty",
"title": "Downtown Retail Space",
"state": "NY",
"hasImages": true,
"price": 3200000,
"city": "New York",
"description": "<p>Prime retail space in the heart of New York City. An excellent opportunity for investors to own a piece of the vibrant downtown shopping district. High foot traffic and visibility make this an ideal location for any retail business.</p>",
"type": "100",
"imageOrder": []
},
{
"id": "3c6d901g-905e-6f59-d098-9dg846280c6g",
"temporary": false,
"userId": "",
"listingsCategory": "commercialProperty",
"title": "Expansive Land Development Opportunity",
"state": "CA",
"hasImages": true,
"price": 5000000,
"city": "Los Angeles",
"description": "<p>Offering a unique opportunity to purchase vast acreage in Los Angeles. Perfect for developers looking to create a new residential community or commercial complex. Accessibility to major highways and urban centers.</p>",
"type": "101",
"imageOrder": []
},
{
"id": "4d7ea012-h16f-7g6a-e109-feh957390d7h",
"temporary": false,
"userId": "",
"listingsCategory": "commercialProperty",
"title": "Industrial Warehouse Complex",
"state": "IL",
"hasImages": true,
"price": 7500000,
"city": "Chicago",
"description": "<p>A modern industrial warehouse complex suitable for manufacturing or distribution businesses. Features multiple loading docks, office spaces, and state-of-the-art facilities. Strategically located for easy transport access.</p>",
"type": "102",
"imageOrder": []
},
{
"id": "5e8fb123-i27g-8h7b-f210-gfi0684a1e8i",
"temporary": false,
"userId": "",
"listingsCategory": "commercialProperty",
"title": "Luxury Office Building",
"state": "TX",
"hasImages": true,
"price": 8900000,
"city": "Houston",
"description": "<p>An architectural masterpiece, this luxury office building offers state-of-the-art amenities, including a fitness center, conference rooms, and rooftop gardens. Ideal for businesses looking for a prestigious address.</p>",
"type": "103",
"imageOrder": []
},
{
"id": "6f9gc234-j38h-9i8c-g311-hgj1795b2f9j",
"temporary": false,
"userId": "",
"listingsCategory": "commercialProperty",
"title": "Mixed-Use Building Downtown",
"state": "FL",
"hasImages": true,
"price": 4200000,
"city": "Miami",
"description": "<p>A prominent mixed-use building featuring retail spaces on the ground floor and modern apartments above. Located in the bustling downtown area, it offers a perfect blend of commercial and residential opportunities.</p>",
"type": "104",
"imageOrder": []
},
{
"id": "7hagd345-k49i-aj9d-h422-ikj2806c3gak",
"temporary": false,
"userId": "",
"listingsCategory": "commercialProperty",
"title": "Multifamily Residential Complex",
"state": "WA",
"hasImages": true,
"price": 6700000,
"city": "Seattle",
"description": "<p>This modern multifamily complex features a variety of amenities including a gym, pool, and community center. Offering a mix of one, two, and three-bedroom units, it's a perfect investment for steady rental income.</p>",
"type": "105",
"imageOrder": []
},
{
"id": "8ibhe456-l5aj-bk0e-i533-jkl3917d4hab",
"temporary": false,
"userId": "",
"listingsCategory": "commercialProperty",
"title": "Versatile Commercial Property",
"state": "CO",
"hasImages": true,
"price": 3900000,
"city": "Denver",
"description": "<p>A versatile commercial property that can accommodate various business types, from retail to offices. Located in a high-traffic area, it offers great visibility and accessibility. Ample parking and flexible space configurations available.</p>",
"type": "106",
"imageOrder": []
},
{
"id": "9jcfi567-m6bk-cl1f-j644-kml4028e5icm",
"temporary": false,
"userId": "",
"listingsCategory": "commercialProperty",
"title": "Premier Shopping Center",
"state": "AZ",
"hasImages": true,
"price": 12000000,
"city": "Phoenix",
"description": "<p>A leading shopping center with a mix of national and local retailers, restaurants, and entertainment options. High annual foot traffic and a loyal customer base make this a highly desirable investment.</p>",
"type": "100",
"imageOrder": []
},
{
"id": "akdjh678-n7cl-dm2g-k755-lnm5139f6jdn",
"temporary": false,
"userId": "",
"listingsCategory": "commercialProperty",
"title": "Expansive Industrial Park",
"state": "NV",
"hasImages": true,
"price": 15000000,
"city": "Las Vegas",
"description": "<p>An expansive industrial park offering a range of facilities for light manufacturing, warehousing, and logistics. Includes customizable units, ample parking, and is strategically located for easy access to major transport routes.</p>",
"type": "102",
"imageOrder": []
},
{
"id": "blemj789-o8dm-en3h-l866-mno6240g7keo",
"temporary": false,
"userId": "",
"listingsCategory": "commercialProperty",
"title": "High-End Office Suites",
"state": "GA",
"hasImages": true,
"price": 5300000,
"city": "Atlanta",
"description": "<p>Elegant and modern office suites in a prestigious part of Atlanta. Offers businesses a high-profile location with top-notch amenities, including secure parking, high-speed internet, and on-site management.</p>",
"type": "103",
"imageOrder": []
}
]

193
crawler/filechooser.ts Normal file
View File

@ -0,0 +1,193 @@
import fs from 'fs-extra';
import path from 'path';
// import { prompt, Question} from 'inquirer';
import inquirer from 'inquirer';
import chalk from 'chalk';
const COMPLETED = 'SELECTION_COMPLETED';
const CANCELLED = 'SELECTION_CANCELLED';
const CHECKMARK = '\u2713';
class FilesSystemService {
directories(directoryPath, directoryFilter ?: (joinedPath) => true) {
return fs.readdirSync(directoryPath).filter((name) => {
const joinedPath = path.join(directoryPath, name);
return this.isDirectory(joinedPath) && directoryFilter(joinedPath);
});
}
files(directoryPath, fileFilter = (joinedPath) => true) {
return fs.readdirSync(directoryPath).filter((name) => {
const joinedPath = path.join(directoryPath, name);
return this.isFile(joinedPath) && fileFilter(joinedPath);
});
}
isDirectory(directoryPath) {
return fs.statSync(directoryPath).isDirectory();
}
isFile(filePath) {
return fs.statSync(filePath).isFile();
}
}
class FilesSelectionService extends Set {
lastFileSelected = null;
constructor(selectedFiles) {
super(selectedFiles);
}
get selectedFiles() {
return Array.from(this);
}
isSelected(file) {
return this.has(file);
}
selectFile(file) {
this.add(file);
this.lastFileSelected = file;
}
removeFile(file) {
this.delete(file);
}
}
class LocationService {
constructor(public currentPath) {
this.currentPath = currentPath;
}
}
class OptionsService {
constructor(public options) {
this.options = { ...this.defaultOptions, ...options };
}
get defaultOptions() {
return {
directoryFilter: () => true,
fileFilter: () => true,
root: process.cwd(),
startingPath: process.cwd(),
multi: true,
pageSize: 10,
selectedFiles: [],
clearConsole: true,
};
}
}
export const selectFiles = function (options = {}) {
const optionsService = new OptionsService(options);
const locationService = new LocationService(
optionsService.options.startingPath
);
const fileSystemService = new FilesSystemService();
const filesSelectionService = new FilesSelectionService(
optionsService.options.selectedFiles
);
return new Promise((resolve):FCResult|void => {
(async function promptUserToSelectFiles() {
const directories = fileSystemService.directories(
locationService.currentPath,
optionsService.options.directoryFilter
);
if (locationService.currentPath !== optionsService.options.root) {
directories.unshift('..');
}
const files = fileSystemService.files(
locationService.currentPath,
optionsService.options.fileFilter
);
const choices = [
...directories.map((directoryName) => {
const value = path.join(locationService.currentPath, directoryName);
const name = chalk.yellow(directoryName);
return { value, name };
}),
...files.map((fileName) => {
const value = path.join(locationService.currentPath, fileName);
const name = `${fileName} ${
filesSelectionService.isSelected(value)
? chalk.green(CHECKMARK)
: ''
}`;
return { value, name };
}),
];
if (filesSelectionService.selectedFiles.length) {
choices.push({
name: chalk.green('-- File Selection Complete --'),
value: COMPLETED,
});
}
choices.push({
name: chalk.red('-- Cancel File Selection --'),
value: CANCELLED,
});
if (optionsService.options.clearConsole) {
console.clear();
}
const { selection } = await inquirer.prompt([
{
type: 'list',
message: `Select file(s) in ${locationService.currentPath}`,
name: 'selection',
pageSize: optionsService.options.pageSize,
choices,
default: () => filesSelectionService.lastFileSelected,
},
]);
if (optionsService.options.clearConsole) {
console.clear();
}
if (selection === COMPLETED || selection === CANCELLED) {
return resolve({
selectedFiles: filesSelectionService.selectedFiles,
status: selection,
});
} else if (!optionsService.options.multi) {
return resolve({
selectedFiles: [selection],
status: COMPLETED,
});
}
if (fileSystemService.isDirectory(selection)) {
locationService.currentPath = selection;
} else {
if (filesSelectionService.isSelected(selection)) {
filesSelectionService.removeFile(selection);
} else {
filesSelectionService.selectFile(selection);
}
}
promptUserToSelectFiles();
})();
});
};
export type FCResult = {
selectedFiles:Array<string>
status:'SELECTION_COMPLETED'|'SELECTION_CANCELLED'
}
// const exports = {
// COMPLETED,
// CANCELLED,
// selectFiles,
// };

View File

@ -1,10 +1,22 @@
import fs from 'fs-extra';
import { selectFiles } from './filechooser.js';
import path from 'path';
(async () => {
const listings = await fs.readJson('./listings.json');
const result = await selectFiles({startingPath:'./data',directoryFilter: (directoryName)=>{
return false;
},fileFilter: (fileName) => {
return /\.json$/gi.test(fileName);
},pageSize:10,multi:false});
console.log(result['selectedFiles'][0]);
const file = result['selectedFiles'][0];
// const extension = path.extname(file);
// const basefileName = path.basename(file,extension);
const listings = await fs.readJson(file);
//listings.forEach(element => {
for (const listing of listings) {
const response = await fetch('http://localhost:3000/bizmatch/listings', {
const type = listing.listingsCategory
const response = await fetch(`http://localhost:3000/bizmatch/listings/${type}`, {
method: 'POST',
body: JSON.stringify(listing),
headers: { 'Content-Type': 'application/json' },

View File

@ -3,6 +3,7 @@
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
@ -15,6 +16,7 @@
"dependencies": {
"currency.js": "^2.0.4",
"fs-extra": "^11.2.0",
"inquirer": "^9.2.17",
"ioredis": "^5.3.2",
"node-fetch": "^3.3.2",
"puppeteer": "^22.1.0",

View File

@ -11,7 +11,7 @@
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"target": "ES2017", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
@ -25,9 +25,9 @@
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
"module": "ESNext", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
@ -55,7 +55,7 @@
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
"outDir": "./build", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */